feat(billing): implement notification for expired payments and refactor SMS handling

This commit is contained in:
2025-07-09 20:13:42 +05:00
parent 1644bd47b9
commit 911d01b8e3

View File

@ -4,16 +4,22 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from api.notifications import send_sms from api.notifications import send_sms
from billing.models import Topup from billing.models import Topup, Payment
from django.utils.timezone import localtime from django.utils.timezone import localtime
from datetime import datetime from datetime import datetime
from enum import Enum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NotificationType(Enum):
TOPUP = "TOPUP"
PAYMENT = "PAYMENT"
@app.periodic( @app.periodic(
cron="*/1 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks" cron="*/1 * * * * *", periodic_id="notify_expired_topups", queue="heavy_tasks"
) # every 1 minute )
@app.task @app.task
def update_expired_topups(timestamp: int): def update_expired_topups(timestamp: int):
expired_topups_qs = Topup.objects.filter( expired_topups_qs = Topup.objects.filter(
@ -33,8 +39,9 @@ def update_expired_topups(timestamp: int):
if topup.user and topup.user.mobile and not topup.expiry_notification_sent: if topup.user and topup.user.mobile and not topup.expiry_notification_sent:
send_sms_task.defer( send_sms_task.defer(
mobile=topup.user.mobile, mobile=topup.user.mobile,
type=NotificationType.TOPUP,
amount=topup.amount, amount=topup.amount,
topup_id=str(topup.id), model_id=str(topup.id),
created_at=localtime(topup.created_at).isoformat(), created_at=localtime(topup.created_at).isoformat(),
user=f"{topup.user.first_name + ' ' + topup.user.last_name}" user=f"{topup.user.first_name + ' ' + topup.user.last_name}"
if topup.user.last_name and topup.user.first_name if topup.user.last_name and topup.user.first_name
@ -43,7 +50,6 @@ def update_expired_topups(timestamp: int):
topup.expiry_notification_sent = True topup.expiry_notification_sent = True
topup.save() topup.save()
else: else:
# Mark as notified even if we can't send SMS (no mobile number)
topup.expiry_notification_sent = True topup.expiry_notification_sent = True
topup.save() topup.save()
return return
@ -53,29 +59,94 @@ def update_expired_topups(timestamp: int):
} }
@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:
if (
payment.user
and payment.user.mobile
and not payment.expiry_notification_sent
):
send_sms_task.defer(
mobile=payment.user.mobile,
type=NotificationType.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 @app.task
def send_sms_task( def send_sms_task(
user: str, mobile: str, amount: float, topup_id: str, created_at: str user: str,
mobile: str,
amount: float,
model_id: str,
created_at: str,
type: NotificationType = NotificationType.TOPUP,
): ):
# Parse the ISO formatted date string
try: try:
dt = datetime.fromisoformat(created_at) dt = datetime.fromisoformat(created_at)
formatted_date = dt.strftime("%d %b %Y, %I:%M %p") formatted_date = dt.strftime("%d %b %Y, %I:%M %p")
except Exception: except Exception:
formatted_date = created_at # fallback to original if parsing fails formatted_date = created_at
message: str = ""
if type == NotificationType.TOPUP:
message = ( message = (
f"Dear {user}, \n\nYour topup of {amount} MVR [created at {formatted_date}] has expired. " 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" "Please make a new topup to update your wallet. \n\n- SAR Link"
) )
elif type == NotificationType.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) send_sms(mobile, message)
logger.info(f"SMS sent to {mobile} for expired topup of {amount} MVR.") logger.info(f"SMS sent to {mobile} for expired {type.value} of {amount} MVR.")
# Mark the topup as notified after successful SMS sending if type == NotificationType.TOPUP:
try: try:
topup = Topup.objects.get(id=topup_id) topup = Topup.objects.get(id=model_id)
topup.expiry_notification_sent = True topup.expiry_notification_sent = True
topup.save() topup.save()
logger.info(f"Marked topup {topup_id} as notified.") logger.info(f"Marked topup {model_id} as notified.")
except Topup.DoesNotExist: except Topup.DoesNotExist:
logger.error(f"Topup {topup_id} not found when trying to mark as notified.") 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."
)