From e41b2b5638c78d5e871a96e17315dd8550494a0d Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Thu, 20 Nov 2025 06:41:05 +0000 Subject: [PATCH 01/10] Changes --- backend/apps/payments/admin.py | 14 ++++++- backend/apps/payments/models.py | 36 +++++++++++++++- backend/apps/payments/serializers.py | 12 ++++++ backend/apps/payments/tests.py | 12 +++++- backend/apps/payments/urls.py | 7 ++++ backend/apps/payments/views.py | 63 +++++++++++++++++++++++++++- 6 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 backend/apps/payments/serializers.py create mode 100644 backend/apps/payments/urls.py diff --git a/backend/apps/payments/admin.py b/backend/apps/payments/admin.py index 8c38f3f..06dbe1e 100644 --- a/backend/apps/payments/admin.py +++ b/backend/apps/payments/admin.py @@ -1,3 +1,15 @@ +# Register your models here. from django.contrib import admin +from .models import HDFCTransactionDetails, AcademicSubscription, PayeeHdfcTransaction -# Register your models here. +@admin.register(HDFCTransactionDetails) +class HDFCTransactionAdmin(admin.ModelAdmin): + list_display = ('order_id', 'transaction_id', 'order_status', 'amount', 'date_created') + +@admin.register(AcademicSubscription) +class AcademicSubscriptionAdmin(admin.ModelAdmin): + list_display = ('user', 'academic_id', 'amount', 'expiry_date') + +@admin.register(PayeeHdfcTransaction) +class PayeeHdfcTransactionAdmin(admin.ModelAdmin): + list_display = ('order_id', 'transaction_id', 'order_status', 'amount') \ No newline at end of file diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py index 71a8362..e5846ca 100644 --- a/backend/apps/payments/models.py +++ b/backend/apps/payments/models.py @@ -1,3 +1,37 @@ from django.db import models +from django.conf import settings -# Create your models here. +class HDFCTransactionDetails(models.Model): + transaction_id = models.CharField(max_length=100) + order_id = models.CharField(max_length=50) + requestId = models.CharField(max_length=100, null=True, blank=True) + amount = models.DecimalField(max_digits=10, decimal_places=2) + order_status = models.CharField(max_length=50, null=True, blank=True) + udf1 = models.TextField(null=True, blank=True) + udf2 = models.TextField(null=True, blank=True) + udf3 = models.TextField(null=True, blank=True) + udf4 = models.TextField(null=True, blank=True) + udf5 = models.TextField(null=True, blank=True) + error_code = models.CharField(max_length=50, null=True, blank=True) + error_message = models.TextField(null=True, blank=True) + date_created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"HDFC Transaction {self.order_id}" + + +class AcademicSubscription(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + academic_id = models.CharField(max_length=100, null=True, blank=True) # Store academic center ID + transaction = models.ForeignKey(HDFCTransactionDetails, on_delete=models.CASCADE) + phone = models.CharField(max_length=20) + amount = models.DecimalField(max_digits=10, decimal_places=2) + expiry_date = models.DateField() + + def __str__(self): + return f"Subscription: {self.user.email} - {self.academic_id}" + + +class PayeeHdfcTransaction(HDFCTransactionDetails): + """Separate model for ILW/Payee-based payments.""" + pass \ No newline at end of file diff --git a/backend/apps/payments/serializers.py b/backend/apps/payments/serializers.py new file mode 100644 index 0000000..dd670fb --- /dev/null +++ b/backend/apps/payments/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import HDFCTransactionDetails, AcademicSubscription + +class HDFCTransactionSerializer(serializers.ModelSerializer): + class Meta: + model = HDFCTransactionDetails + fields = '__all__' + +class AcademicSubscriptionSerializer(serializers.ModelSerializer): + class Meta: + model = AcademicSubscription + fields = '__all__' \ No newline at end of file diff --git a/backend/apps/payments/tests.py b/backend/apps/payments/tests.py index 7ce503c..6b52fdb 100644 --- a/backend/apps/payments/tests.py +++ b/backend/apps/payments/tests.py @@ -1,3 +1,13 @@ -from django.test import TestCase +#from django.test import TestCase # Create your tests here. +from rest_framework.test import APITestCase +from django.urls import reverse + +class PaymentTests(APITestCase): + + def test_create_session(self): + url = reverse('create-academic-session') + data = {"email": "test@example.com", "academic_ids": [1], "amount": 1000} + response = self.client.post(url, data, format='json') + self.assertIn(response.status_code, [200, 400]) \ No newline at end of file diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py new file mode 100644 index 0000000..eab35e6 --- /dev/null +++ b/backend/apps/payments/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('academic/session/', views.create_academic_payment_session, name='create-academic-session'), + path('academic/status//', views.check_payment_status, name='check-academic-status'), +] \ No newline at end of file diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index 91ea44a..bc4dbad 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -1,3 +1,62 @@ -from django.shortcuts import render +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework import status +from django.urls import reverse +from django.http import JsonResponse +from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction +from .serializers import HDFCTransactionSerializer +from .utils.hdfc_utils import generate_hashed_order_id, get_request_headers, poll_payment_status +from decimal import Decimal +import requests -# Create your views here. +@api_view(['POST']) +def create_academic_payment_session(request): + """Creates payment session for academic subscription""" + data = request.data + email = data.get('email') + academic_ids = data.get('academic_ids', []) + amount = data.get('amount') + + payload = { + "order_id": generate_hashed_order_id(email), + "amount": str(amount), + "customer_id": email, + "customer_email": email, + "customer_phone": data.get("phone"), + "payment_page_client_id": "your_client_id_here", + "action": "paymentPage", + "return_url": request.build_absolute_uri(reverse('payment-callback')), + "description": "Complete Academic Subscription Payment...", + "udf3": data.get('name'), + "udf4": data.get('state') + } + + # AcademicCenter lookup removed - module 'events' not available + # values = AcademicCenter.objects.filter(id__in=academic_ids).values('institution_name', 'academic_code') + # payload["udf1"] = ' ** '.join([v['institution_name'] for v in values])[:90] + # payload["udf2"] = ' ** '.join([v['academic_code'] for v in values]) + + headers = get_request_headers(email) + try: + response = requests.post(settings.HDFC_API_URL, json=payload, headers=headers) + response_data = response.json() + if response.status_code == 200: + transaction = HDFCTransactionDetails.objects.create( + transaction_id=response_data.get("id"), + order_id=response_data.get("order_id"), + amount=amount + ) + return Response({"payment_link": response_data.get("payment_links", {}).get("web")}) + else: + return Response(response_data, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['GET']) +def check_payment_status(request, order_id): + """Polls the payment gateway for order status""" + email = request.query_params.get("email") + amount = request.query_params.get("amount") + result = poll_payment_status(order_id, email, Decimal(amount)) + return Response(result) \ No newline at end of file From d246661659b695369adabbbb0810b426e684ded8 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Sun, 23 Nov 2025 18:55:30 +0000 Subject: [PATCH 02/10] migrations --- .../apps/spoken/migrations/0001_initial.py | 143 ++++++++++++++++++ backend/config/settings/base.py | 1 + 2 files changed, 144 insertions(+) create mode 100644 backend/apps/spoken/migrations/0001_initial.py diff --git a/backend/apps/spoken/migrations/0001_initial.py b/backend/apps/spoken/migrations/0001_initial.py new file mode 100644 index 0000000..ac3a167 --- /dev/null +++ b/backend/apps/spoken/migrations/0001_initial.py @@ -0,0 +1,143 @@ +# Generated by Django 5.2.5 on 2025-11-20 07:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CreationDomain', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, unique=True)), + ('created', models.DateTimeField()), + ('updated', models.DateTimeField()), + ('show_on_homepage', models.IntegerField()), + ('icon', models.ImageField(blank=True, null=True, upload_to='domain_icons/')), + ('description', models.TextField()), + ('is_active', models.BooleanField()), + ], + options={ + 'db_table': 'creation_domain', + 'managed': False, + }, + ), + migrations.CreateModel( + name='CreationFosscategory', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('foss', models.CharField(max_length=255, unique=True)), + ('description', models.TextField()), + ('status', models.IntegerField()), + ('user_id', models.IntegerField(db_column='user_id')), + ('created', models.DateTimeField()), + ('updated', models.DateTimeField()), + ('is_learners_allowed', models.IntegerField()), + ('show_on_homepage', models.PositiveSmallIntegerField()), + ('is_translation_allowed', models.IntegerField()), + ('available_for_nasscom', models.IntegerField()), + ('available_for_jio', models.IntegerField()), + ('csc_dca_programme', models.IntegerField()), + ('credits', models.PositiveSmallIntegerField()), + ('is_fossee', models.IntegerField()), + ('icon', models.ImageField(blank=True, null=True, upload_to='foss_icons/')), + ], + options={ + 'db_table': 'creation_fosscategory', + 'managed': False, + }, + ), + migrations.CreateModel( + name='CreationFosscategoryDomain', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('is_primary', models.IntegerField()), + ], + options={ + 'db_table': 'creation_fosscategorydomain', + 'managed': False, + }, + ), + migrations.CreateModel( + name='CreationLanguage', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, unique=True)), + ('code', models.CharField(max_length=10)), + ('user', models.IntegerField()), + ('created', models.DateTimeField()), + ('updated', models.DateTimeField()), + ], + options={ + 'db_table': 'creation_language', + 'managed': False, + }, + ), + migrations.CreateModel( + name='CreationLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.CharField(max_length=255)), + ('code', models.CharField(max_length=10)), + ], + options={ + 'db_table': 'creation_level', + 'managed': False, + }, + ), + migrations.CreateModel( + name='CreationTutorialdetail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tutorial', models.CharField(max_length=255)), + ('order', models.IntegerField()), + ('user_id', models.IntegerField(db_column='user_id')), + ('created', models.DateTimeField()), + ('updated', models.DateTimeField()), + ], + options={ + 'db_table': 'creation_tutorialdetail', + 'managed': False, + }, + ), + migrations.CreateModel( + name='CreationTutorialresource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('common_content_id', models.IntegerField(db_column='common_content_id')), + ('outline', models.TextField()), + ('outline_user_id', models.IntegerField(db_column='outline_user_id')), + ('outline_status', models.PositiveSmallIntegerField()), + ('script', models.CharField(max_length=255)), + ('script_user_id', models.IntegerField(db_column='script_user_id')), + ('script_status', models.PositiveSmallIntegerField()), + ('timed_script', models.CharField(max_length=255)), + ('video', models.CharField(max_length=255)), + ('video_id', models.CharField(blank=True, max_length=255, null=True)), + ('playlist_item_id', models.CharField(blank=True, max_length=255, null=True)), + ('video_thumbnail_time', models.TimeField()), + ('video_user_id', models.IntegerField(db_column='video_user_id')), + ('video_status', models.PositiveSmallIntegerField()), + ('status', models.PositiveSmallIntegerField()), + ('version', models.PositiveSmallIntegerField()), + ('hit_count', models.PositiveIntegerField()), + ('created', models.DateTimeField()), + ('updated', models.DateTimeField()), + ('publish_at', models.DateTimeField(blank=True, null=True)), + ('assignment_status', models.PositiveSmallIntegerField()), + ('extension_status', models.PositiveIntegerField()), + ('submissiondate', models.DateTimeField()), + ('is_unrestricted', models.IntegerField()), + ], + options={ + 'db_table': 'creation_tutorialresource', + 'managed': False, + }, + ), + ] diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index f6610e3..75c70a8 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -30,6 +30,7 @@ "apps.spoken", "apps.cms", "apps.users", + "apps.payments", ] MIDDLEWARE = [ From e46970aa62d44ffd5d898a634276742c60b38641 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Mon, 24 Nov 2025 08:17:35 +0000 Subject: [PATCH 03/10] API --- backend/apps/payments/urls.py | 1 + backend/apps/payments/views.py | 13 ++++++++++++- backend/config/settings/base.py | 12 +++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py index eab35e6..3062308 100644 --- a/backend/apps/payments/urls.py +++ b/backend/apps/payments/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path('academic/session/', views.create_academic_payment_session, name='create-academic-session'), path('academic/status//', views.check_payment_status, name='check-academic-status'), + path('payment/callback/', views.payment_callback, name='payment-callback'), ] \ No newline at end of file diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index bc4dbad..048866d 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -1,6 +1,7 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from rest_framework import status +from django.conf import settings from django.urls import reverse from django.http import JsonResponse from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction @@ -59,4 +60,14 @@ def check_payment_status(request, order_id): email = request.query_params.get("email") amount = request.query_params.get("amount") result = poll_payment_status(order_id, email, Decimal(amount)) - return Response(result) \ No newline at end of file + return Response(result) + +@api_view(['POST']) +def payment_callback(request): + data = request.data + + # validate checksum here + # verify amount/order_id + # update transaction model + + return Response({"status": "received"}) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 75c70a8..4bcc9ad 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -152,4 +152,14 @@ # 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', # 'django.contrib.auth.hashers.CryptPasswordHasher', -] \ No newline at end of file +] + + +HDFC_CONFIG = { + "MERCHANT_ID": os.getenv("MERCHANT_ID"), + "CLIENT_ID": os.getenv("CLIENT_ID"), + "RESPONSE_KEY": os.getenv("RESPONSE_KEY"), + "HDFC_API_URL": os.getenv("HDFC_API_URL"), + "HDFC_API_KEY": os.getenv("HDFC_API_KEY"), + "ORDER_STATUS_URL": os.getenv("ORDER_STATUS_URL"), +} \ No newline at end of file From b98699ea38730f1cc567a0ea15cc7835b08249b8 Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Mon, 24 Nov 2025 16:10:21 +0000 Subject: [PATCH 04/10] fetched the payment status file from a2 branch --- frontend/src/App.tsx | 36 +-- frontend/src/pages/public/PaymentStatus.tsx | 287 ++++++++++++++++++++ 2 files changed, 296 insertions(+), 27 deletions(-) create mode 100644 frontend/src/pages/public/PaymentStatus.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c8e0be..da56684 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,18 +1,13 @@ import { Routes, Route } from "react-router-dom"; +import ResponsiveAppBar from "./components/homepage/ResponsiveAppBar"; import MegaMenu from "./components/homepage/AppBar"; // import FeatureTiles from "./components/homepage/HomeComponents"; import HomePage from "./pages/home/Homepage"; import DomainPage from "./pages/public/DomainsPage"; import CoursePage from "./pages/public/CoursePage"; import TutorialSearch from "./pages/public/TutorialSearch"; -import SubscriptionPage from "./pages/public/SubscriptionPage"; -import LoginPage from "./features/auth/pages/LoginPage"; -import DashboardLayout from "./features/dashboard/pages/DashboardLayout"; -import PublicLayout from "./pages/public/PublicLayout"; -import Dashboard from "./features/dashboard/pages/Dashboard"; -import TrainingPlanner from "./features/training/pages/TrainingPlanner"; -import TrainingAttendance from "./features/training/pages/TrainingAttendance"; +import PaymentStatus from "./pages/public/PaymentStatus"; export default function App(){ @@ -20,28 +15,15 @@ export default function App(){ return ( <> {/* */} - {/* */} + {/* */} - + {/* Define the routes */} - {/* Public routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Dashboard routes */} - }> - } /> - } /> - } /> - {/* } /> */} - - + } /> + } /> + } /> + } /> + } /> {/* catch-all for 404 */} Page Not Found} /> diff --git a/frontend/src/pages/public/PaymentStatus.tsx b/frontend/src/pages/public/PaymentStatus.tsx new file mode 100644 index 0000000..eb241e7 --- /dev/null +++ b/frontend/src/pages/public/PaymentStatus.tsx @@ -0,0 +1,287 @@ +import { Box, Container, Card, CardHeader, CardContent, Typography, List, ListItem, ListItemText, Stack, Button } from "@mui/material"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CancelIcon from "@mui/icons-material/Cancel"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import WarningIcon from "@mui/icons-material/Warning"; +import { BRAND } from "../../theme"; + +interface PaymentData { + order_id: string; + id: string; + udf3: string; + customer_email: string; + customer_phone: string; + amount: string; + udf1: string; + udf2: string; + udf4: string; + date_created: string; +} + +type PaymentStatus = "CHARGED" | "FAILED" | "PENDING" | "TIMEOUT" | "ERROR"; + +export default function PaymentStatus() { + const navigate = useNavigate(); + const [status] = useState("CHARGED"); + + const paymentData: PaymentData = { + order_id: "ORD-2025-001234", + id: "TXN-HDFC-5678901", + udf3: "John Doe", + customer_email: "john.doe@example.com", + customer_phone: "+91 98765 43210", + amount: "5,000", + udf1: "Delhi Academic Center", + udf2: "DAC-001", + udf4: "Delhi", + date_created: "13 Nov 2025, 10:30 AM", + }; + + return ( + + + + + + Transaction Details + + + } + sx={{ + backgroundColor: BRAND.lightBgHighlight, + borderBottom: `1px solid ${BRAND.borderColor}`, + "& .MuiCardHeader-title": { + display: "flex", + alignItems: "center", + gap: 1, + }, + }} + /> + + + {/* Status Message */} + + {status === "CHARGED" && ( + + + + The payment was successful! + + + )} + + {status === "FAILED" && ( + + + + Payment failed. Please contact Training Manager. + + + )} + + {status === "PENDING" && ( + + + + Your transaction is being processed. Please do not close this window while we retrieve the latest details. + + + )} + + {status === "TIMEOUT" && ( + + + + We are still processing your payment. Don't worry! You will receive an email confirmation once the transaction is complete. + + + )} + + {status === "ERROR" && ( + + + + Error checking payment status. Please contact Training Manager. + + + )} + + + {/* Payment Details */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Action Buttons */} + + {status === "FAILED" && ( + + )} + {status === "CHARGED" && ( + + )} + + + + + + ); +} From 94725a3f2066d7992ec2e2e64bdeecbb91f2ff17 Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Mon, 24 Nov 2025 18:31:16 +0000 Subject: [PATCH 05/10] integrated the frontend for subscription page with the backend while making necessary changed, ensured that HDFC api is working and provided a payment receipt page after successful transaction --- .../apps/payments/migrations/0001_initial.py | 55 ++ ...2_hdfctransactiondetails_customer_phone.py | 18 + ...3_hdfctransactiondetails_customer_email.py | 18 + backend/apps/payments/models.py | 2 + backend/apps/payments/urls.py | 2 + backend/apps/payments/utils/__init__.py | 0 backend/apps/payments/utils/hdfc_utils.py | 68 +++ backend/apps/payments/views.py | 78 ++- backend/config/settings/base.py | 17 +- backend/config/urls.py | 1 + frontend/src/App.tsx | 4 +- frontend/src/pages/public/PaymentStatus.tsx | 557 ++++++++++-------- .../src/pages/public/SubscriptionPage.tsx | 136 +++-- 13 files changed, 661 insertions(+), 295 deletions(-) create mode 100644 backend/apps/payments/migrations/0001_initial.py create mode 100644 backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py create mode 100644 backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py create mode 100644 backend/apps/payments/utils/__init__.py create mode 100644 backend/apps/payments/utils/hdfc_utils.py diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..f712de8 --- /dev/null +++ b/backend/apps/payments/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.5 on 2025-11-24 10:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HDFCTransactionDetails', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_id', models.CharField(max_length=100)), + ('order_id', models.CharField(max_length=50)), + ('requestId', models.CharField(blank=True, max_length=100, null=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('order_status', models.CharField(blank=True, max_length=50, null=True)), + ('udf1', models.TextField(blank=True, null=True)), + ('udf2', models.TextField(blank=True, null=True)), + ('udf3', models.TextField(blank=True, null=True)), + ('udf4', models.TextField(blank=True, null=True)), + ('udf5', models.TextField(blank=True, null=True)), + ('error_code', models.CharField(blank=True, max_length=50, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='PayeeHdfcTransaction', + fields=[ + ('hdfctransactiondetails_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='payments.hdfctransactiondetails')), + ], + bases=('payments.hdfctransactiondetails',), + ), + migrations.CreateModel( + name='AcademicSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('academic_id', models.CharField(blank=True, max_length=100, null=True)), + ('phone', models.CharField(max_length=20)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('expiry_date', models.DateField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), + ], + ), + ] diff --git a/backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py b/backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py new file mode 100644 index 0000000..3606111 --- /dev/null +++ b/backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-11-24 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='hdfctransactiondetails', + name='customer_phone', + field=models.CharField(blank=True, max_length=20, null=True), + ), + ] diff --git a/backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py b/backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py new file mode 100644 index 0000000..af8f2be --- /dev/null +++ b/backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-11-24 18:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0002_hdfctransactiondetails_customer_phone'), + ] + + operations = [ + migrations.AddField( + model_name='hdfctransactiondetails', + name='customer_email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + ] diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py index e5846ca..56a7891 100644 --- a/backend/apps/payments/models.py +++ b/backend/apps/payments/models.py @@ -12,6 +12,8 @@ class HDFCTransactionDetails(models.Model): udf3 = models.TextField(null=True, blank=True) udf4 = models.TextField(null=True, blank=True) udf5 = models.TextField(null=True, blank=True) + customer_email = models.EmailField(null=True, blank=True) + customer_phone = models.CharField(max_length=20, null=True, blank=True) error_code = models.CharField(max_length=50, null=True, blank=True) error_message = models.TextField(null=True, blank=True) date_created = models.DateTimeField(auto_now_add=True) diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py index 3062308..33440d5 100644 --- a/backend/apps/payments/urls.py +++ b/backend/apps/payments/urls.py @@ -4,5 +4,7 @@ urlpatterns = [ path('academic/session/', views.create_academic_payment_session, name='create-academic-session'), path('academic/status//', views.check_payment_status, name='check-academic-status'), + path('transaction//', views.get_transaction_details, name='get-transaction-details'), + path('callback-handler/', views.callback_handler, name='callback-handler'), path('payment/callback/', views.payment_callback, name='payment-callback'), ] \ No newline at end of file diff --git a/backend/apps/payments/utils/__init__.py b/backend/apps/payments/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/payments/utils/hdfc_utils.py b/backend/apps/payments/utils/hdfc_utils.py new file mode 100644 index 0000000..f4e339d --- /dev/null +++ b/backend/apps/payments/utils/hdfc_utils.py @@ -0,0 +1,68 @@ +import hashlib +import time +import base64 +import hmac +import urllib.parse +import json +import requests as http_requests +from django.conf import settings +from ..models import HDFCTransactionDetails + +def generate_hashed_order_id(email): + data = f"{email}{int(time.time())}" + return hashlib.sha256(data.encode()).hexdigest()[:15].upper() + +def get_request_headers(email): + encoded_api_key = base64.b64encode(f"{settings.HDFC_API_KEY}:".encode()).decode() + return { + "Authorization": f"Basic {encoded_api_key}", + "Content-Type": "application/json", + "x-merchantid": settings.MERCHANT_ID, + "x-customerid": email + } + +def verify_hmac_signature(params): + key = settings.RESPONSE_KEY.encode() + data = params.copy() + signature_algorithm = data.pop('signature_algorithm', None) + signature = data.pop('signature', [None])[0] + if not signature: + return False + + encoded_params = { + urllib.parse.quote_plus(str(k)): urllib.parse.quote_plus(str(v)) + for k, v in data.items() + } + encoded_string = '&'.join(f"{k}={encoded_params[k]}" for k in sorted(encoded_params)) + p_encoded_string = urllib.parse.quote_plus(encoded_string) + dig = hmac.new(key, msg=p_encoded_string.encode(), digestmod=hashlib.sha256).digest() + computed_sign = base64.b64encode(dig).decode() + return computed_sign == signature + +def poll_payment_status(order_id, email, sub_amount, model=HDFCTransactionDetails): + url = f"{settings.ORDER_STATUS_URL}{order_id}" + headers = get_request_headers(email) + attempt = 0 + while attempt < settings.HDFC_POLL_MAX_RETRIES: + try: + response = http_requests.get(url, headers=headers) + data = response.json() + if response.status_code == 200 and "status" in data: + payment_status = data.get('status', '') + if payment_status == 'CHARGED': + obj = model.objects.get(order_id=order_id) + obj.order_status = 'CHARGED' + obj.save() + return {"status": "CHARGED"} + elif payment_status in ["AUTHENTICATION_FAILED", "AUTHORIZATION_FAILED"]: + return {"status": "FAILED"} + except Exception as e: + return {"status": "ERROR", "message": str(e)} + time.sleep(settings.HDFC_POLL_INTERVAL) + attempt += 1 + return {"status": "TIMEOUT"} + + +def create_hdfc_session(payload, headers): + return http_requests.post(settings.HDFC_API_URL, json=payload, headers=headers) + diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index 048866d..a9ce63d 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -2,8 +2,8 @@ from rest_framework.response import Response from rest_framework import status from django.conf import settings -from django.urls import reverse from django.http import JsonResponse +from django.shortcuts import redirect from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction from .serializers import HDFCTransactionSerializer from .utils.hdfc_utils import generate_hashed_order_id, get_request_headers, poll_payment_status @@ -26,7 +26,7 @@ def create_academic_payment_session(request): "customer_phone": data.get("phone"), "payment_page_client_id": "your_client_id_here", "action": "paymentPage", - "return_url": request.build_absolute_uri(reverse('payment-callback')), + "return_url": request.build_absolute_uri("/api/payments/callback-handler/"), "description": "Complete Academic Subscription Payment...", "udf3": data.get('name'), "udf4": data.get('state') @@ -42,12 +42,24 @@ def create_academic_payment_session(request): response = requests.post(settings.HDFC_API_URL, json=payload, headers=headers) response_data = response.json() if response.status_code == 200: + transaction_id = response_data.get("id") + request_id = response_data.get("requestId") + transaction = HDFCTransactionDetails.objects.create( - transaction_id=response_data.get("id"), + transaction_id=transaction_id, order_id=response_data.get("order_id"), - amount=amount + requestId=request_id, + amount=amount, + customer_email=email, + customer_phone=data.get("phone"), + udf3=data.get('name'), + udf4=data.get('state') ) - return Response({"payment_link": response_data.get("payment_links", {}).get("web")}) + + return Response({ + "payment_link": response_data.get("payment_links", {}).get("web"), + "transaction_id": transaction_id + }) else: return Response(response_data, status=status.HTTP_400_BAD_REQUEST) except Exception as e: @@ -62,6 +74,26 @@ def check_payment_status(request, order_id): result = poll_payment_status(order_id, email, Decimal(amount)) return Response(result) + +@api_view(['GET']) +def get_transaction_details(request, transaction_id): + """Fetch transaction details by transaction_id""" + try: + transaction = HDFCTransactionDetails.objects.get(transaction_id=transaction_id) + serializer = HDFCTransactionSerializer(transaction) + return Response(serializer.data) + except HDFCTransactionDetails.DoesNotExist: + return Response( + {"error": "Transaction not found"}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {"error": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @api_view(['POST']) def payment_callback(request): data = request.data @@ -71,3 +103,39 @@ def payment_callback(request): # update transaction model return Response({"status": "received"}) + + +@api_view(['GET', 'POST']) +def callback_handler(request): + """Handle HDFC callback and redirect to frontend payment status page""" + # Try to get transaction ID from various sources + transaction_id = ( + request.GET.get('id') or + request.GET.get('transaction_id') or + request.GET.get('txnId') or + request.GET.get('requestId') + ) + + # Also try order_id which HDFC might send + order_id = ( + request.GET.get('order_id') or + request.GET.get('orderId') or + request.POST.get('order_id') or + request.POST.get('orderId') + ) + + # If we have order_id, look up the transaction + if order_id and not transaction_id: + try: + transaction = HDFCTransactionDetails.objects.get(order_id=order_id) + transaction_id = transaction.transaction_id + except HDFCTransactionDetails.DoesNotExist: + pass + + if not transaction_id: + # Fallback: redirect to subscription page with error + return redirect("http://localhost:5173/subscription?error=payment_callback_missing_id") + + # Redirect to frontend payment status page + frontend_url = f"http://localhost:5173/payment-status/{transaction_id}" + return redirect(frontend_url) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 4bcc9ad..4bb0619 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -155,11 +155,12 @@ ] -HDFC_CONFIG = { - "MERCHANT_ID": os.getenv("MERCHANT_ID"), - "CLIENT_ID": os.getenv("CLIENT_ID"), - "RESPONSE_KEY": os.getenv("RESPONSE_KEY"), - "HDFC_API_URL": os.getenv("HDFC_API_URL"), - "HDFC_API_KEY": os.getenv("HDFC_API_KEY"), - "ORDER_STATUS_URL": os.getenv("ORDER_STATUS_URL"), -} \ No newline at end of file +# HDFC settings +MERCHANT_ID = os.getenv("MERCHANT_ID") +CLIENT_ID = os.getenv("CLIENT_ID") +RESPONSE_KEY = os.getenv("RESPONSE_KEY") +HDFC_API_URL = os.getenv("HDFC_API_URL") +HDFC_API_KEY = os.getenv("HDFC_API_KEY") +ORDER_STATUS_URL = os.getenv("ORDER_STATUS_URL") +HDFC_POLL_MAX_RETRIES = int(os.getenv("HDFC_POLL_MAX_RETRIES", "5")) +HDFC_POLL_INTERVAL = int(os.getenv("HDFC_POLL_INTERVAL", "2")) \ No newline at end of file diff --git a/backend/config/urls.py b/backend/config/urls.py index 662e06c..75fe68b 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -24,6 +24,7 @@ path("api/auth/", include("apps.users.urls")), path("api/", include("apps.core.urls")), path("api/", include("apps.spoken.urls")), + path("api/payments/", include("apps.payments.urls")), ] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da56684..8bd3add 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import DomainPage from "./pages/public/DomainsPage"; import CoursePage from "./pages/public/CoursePage"; import TutorialSearch from "./pages/public/TutorialSearch"; import PaymentStatus from "./pages/public/PaymentStatus"; +import SubscriptionPage from "./pages/public/SubscriptionPage"; export default function App(){ @@ -22,8 +23,9 @@ export default function App(){ } /> } /> } /> + } /> } /> - } /> + } /> {/* catch-all for 404 */} Page Not Found} /> diff --git a/frontend/src/pages/public/PaymentStatus.tsx b/frontend/src/pages/public/PaymentStatus.tsx index eb241e7..2131f80 100644 --- a/frontend/src/pages/public/PaymentStatus.tsx +++ b/frontend/src/pages/public/PaymentStatus.tsx @@ -1,6 +1,6 @@ -import { Box, Container, Card, CardHeader, CardContent, Typography, List, ListItem, ListItemText, Stack, Button } from "@mui/material"; -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Box, Container, Card, CardHeader, CardContent, Typography, List, ListItem, ListItemText, Stack, Button, CircularProgress } from "@mui/material"; +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CancelIcon from "@mui/icons-material/Cancel"; import AccessTimeIcon from "@mui/icons-material/AccessTime"; @@ -8,8 +8,8 @@ import WarningIcon from "@mui/icons-material/Warning"; import { BRAND } from "../../theme"; interface PaymentData { + transaction_id: string; order_id: string; - id: string; udf3: string; customer_email: string; customer_phone: string; @@ -18,269 +18,334 @@ interface PaymentData { udf2: string; udf4: string; date_created: string; + order_status: string; } type PaymentStatus = "CHARGED" | "FAILED" | "PENDING" | "TIMEOUT" | "ERROR"; export default function PaymentStatus() { const navigate = useNavigate(); - const [status] = useState("CHARGED"); - - const paymentData: PaymentData = { - order_id: "ORD-2025-001234", - id: "TXN-HDFC-5678901", - udf3: "John Doe", - customer_email: "john.doe@example.com", - customer_phone: "+91 98765 43210", - amount: "5,000", - udf1: "Delhi Academic Center", - udf2: "DAC-001", - udf4: "Delhi", - date_created: "13 Nov 2025, 10:30 AM", - }; + const { transactionId } = useParams<{ transactionId: string }>(); + const [status, setStatus] = useState("PENDING"); + const [paymentData, setPaymentData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!transactionId) { + setError("No transaction ID provided"); + setLoading(false); + return; + } + + const fetchTransactionDetails = async () => { + try { + setLoading(true); + const response = await fetch( + `http://localhost:8000/api/payments/transaction/${transactionId}/` + ); + + if (!response.ok) { + throw new Error("Failed to fetch transaction details"); + } + + const data = await response.json(); + setPaymentData(data); + setError(null); // Clear error when data loads successfully + + // Determine status based on order_status + if (data.order_status === "CHARGED") { + setStatus("CHARGED"); + } else if (data.order_status === "FAILED" || data.order_status === "NO_TRANSACTION") { + setStatus("FAILED"); + } else if (data.order_status === "PENDING" || data.order_status === "PENDING_VBV" || data.order_status === "AUTHORIZING") { + setStatus("PENDING"); + } else if (!data.order_status || data.order_status === "" || data.order_status === null) { + // If no status yet, assume CHARGED (transaction was created successfully) + setStatus("CHARGED"); + } else { + setStatus("PENDING"); // Default to pending for unknown statuses + } + } catch (err) { + console.error("Error fetching transaction:", err); + setError(err instanceof Error ? err.message : "An error occurred"); + setStatus("ERROR"); + } finally { + setLoading(false); + } + }; + + fetchTransactionDetails(); + }, [transactionId]); return ( - - - - Transaction Details - - - } + {loading && ( + + + + )} + + {error && !loading && ( + + > + {error} + + )} - - {/* Status Message */} - - {status === "CHARGED" && ( - - - - The payment was successful! + {!loading && paymentData && ( + + + + Transaction Details - )} + } + sx={{ + backgroundColor: BRAND.lightBgHighlight, + borderBottom: `1px solid ${BRAND.borderColor}`, + "& .MuiCardHeader-title": { + display: "flex", + alignItems: "center", + gap: 1, + }, + }} + /> - {status === "FAILED" && ( - - - - Payment failed. Please contact Training Manager. - - - )} + + {/* Status Message */} + + {status === "CHARGED" && ( + + + + The payment was successful! + + + )} - {status === "PENDING" && ( - - - - Your transaction is being processed. Please do not close this window while we retrieve the latest details. - - - )} + {status === "FAILED" && ( + + + + Payment failed. Please contact Training Manager. + + + )} - {status === "TIMEOUT" && ( - - - - We are still processing your payment. Don't worry! You will receive an email confirmation once the transaction is complete. - - - )} + {status === "PENDING" && ( + + + + Your transaction is being processed. Please do not close this window while we retrieve the latest details. + + + )} - {status === "ERROR" && ( - - - - Error checking payment status. Please contact Training Manager. - - - )} - + {status === "TIMEOUT" && ( + + + + We are still processing your payment. Don't worry! You will receive an email confirmation once the transaction is complete. + + + )} - {/* Payment Details */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {status === "ERROR" && ( + + + + Error checking payment status. Please contact Training Manager. + + + )} + + + {/* Payment Details */} + + + + + + + + + + + + + + + + + + + + {paymentData.udf1 && ( + + + + )} + {paymentData.udf2 && ( + + + + )} + + + + + + + - {/* Action Buttons */} - - {status === "FAILED" && ( - - )} - {status === "CHARGED" && ( - - )} - - - + {/* Action Buttons */} + + {status === "FAILED" && ( + + )} + {status === "CHARGED" && ( + + )} + + + + )} ); diff --git a/frontend/src/pages/public/SubscriptionPage.tsx b/frontend/src/pages/public/SubscriptionPage.tsx index 6042a63..7856866 100644 --- a/frontend/src/pages/public/SubscriptionPage.tsx +++ b/frontend/src/pages/public/SubscriptionPage.tsx @@ -30,7 +30,6 @@ import PaymentIcon from '@mui/icons-material/Payment'; import PersonIcon from '@mui/icons-material/Person'; import EmailIcon from '@mui/icons-material/Email'; import PhoneIcon from '@mui/icons-material/Phone'; -import LocationOnIcon from '@mui/icons-material/LocationOn'; import BuildingIcon from '@mui/icons-material/Business'; import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee'; import HistoryIcon from '@mui/icons-material/History'; @@ -58,6 +57,67 @@ interface Transaction { order_status: string; } +// Dummy data for states +const DUMMY_STATES = [ + { id: 1, name: 'Andhra Pradesh' }, + { id: 2, name: 'Arunachal Pradesh' }, + { id: 3, name: 'Assam' }, + { id: 4, name: 'Bihar' }, + { id: 5, name: 'Chhattisgarh' }, + { id: 6, name: 'Goa' }, + { id: 7, name: 'Gujarat' }, + { id: 8, name: 'Haryana' }, + { id: 9, name: 'Himachal Pradesh' }, + { id: 10, name: 'Jharkhand' }, + { id: 11, name: 'Karnataka' }, + { id: 12, name: 'Kerala' }, + { id: 13, name: 'Madhya Pradesh' }, + { id: 14, name: 'Maharashtra' }, + { id: 15, name: 'Manipur' }, + { id: 16, name: 'Meghalaya' }, + { id: 17, name: 'Mizoram' }, + { id: 18, name: 'Nagaland' }, + { id: 19, name: 'Odisha' }, + { id: 20, name: 'Punjab' }, + { id: 21, name: 'Rajasthan' }, + { id: 22, name: 'Sikkim' }, + { id: 23, name: 'Tamil Nadu' }, + { id: 24, name: 'Telangana' }, + { id: 25, name: 'Tripura' }, + { id: 26, name: 'Uttar Pradesh' }, + { id: 27, name: 'Uttarakhand' }, + { id: 28, name: 'West Bengal' }, +]; + +// Dummy data for academic centers per state +const DUMMY_ACADEMIC_CENTERS: { [key: string]: AcademicCenter[] } = { + '1': [ + { id: 101, academic_code: 'AP001', institution_name: 'Andhra University, Visakhapatnam' }, + { id: 102, academic_code: 'AP002', institution_name: 'Sri Venkateswara University, Tirupati' }, + { id: 103, academic_code: 'AP003', institution_name: 'Osmania University, Hyderabad' }, + ], + '2': [ + { id: 201, academic_code: 'AR001', institution_name: 'North Eastern University, Itanagar' }, + { id: 202, academic_code: 'AR002', institution_name: 'Delhi Skill University, Arunachal Campus' }, + ], + '3': [ + { id: 301, academic_code: 'AS001', institution_name: 'Gauhati University, Guwahati' }, + { id: 302, academic_code: 'AS002', institution_name: 'Dibrugarh University, Dibrugarh' }, + { id: 303, academic_code: 'AS003', institution_name: 'Indian Institute of Technology Guwahati' }, + ], + '14': [ + { id: 1401, academic_code: 'MH001', institution_name: 'University of Mumbai, Mumbai' }, + { id: 1402, academic_code: 'MH002', institution_name: 'Indian Institute of Technology Bombay' }, + { id: 1403, academic_code: 'MH003', institution_name: 'Pune University, Pune' }, + { id: 1404, academic_code: 'MH004', institution_name: 'NMIMS University, Mumbai' }, + ], + '26': [ + { id: 2601, academic_code: 'UP001', institution_name: 'University of Lucknow, Lucknow' }, + { id: 2602, academic_code: 'UP002', institution_name: 'Indian Institute of Technology BHU, Varanasi' }, + { id: 2603, academic_code: 'UP003', institution_name: 'Aligarh Muslim University, Aligarh' }, + ], +}; + const SubscriptionPage: React.FC = () => { const theme = useTheme(); const [formData, setFormData] = useState({ @@ -69,22 +129,14 @@ const SubscriptionPage: React.FC = () => { const [selectedInstitutes, setSelectedInstitutes] = useState([]); const [academicCenters, setAcademicCenters] = useState([]); - const [amount, setAmount] = useState(0); + const [amount, setAmount] = useState(''); const [gstFields, setGSTFields] = useState({}); const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [loadingCenters, setLoadingCenters] = useState(false); - const [subscriptionAmount] = useState(5000); // Example amount const [userTransactions] = useState([]); - const [isAuthenticated] = useState(false); // This should come from your auth context + const [isAuthenticated] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); - const states = [ - { id: 1, name: 'Andhra Pradesh' }, - { id: 2, name: 'Arunachal Pradesh' }, - { id: 3, name: 'Assam' }, - // Add more states as needed - ]; - useEffect(() => { if (formData.state) { fetchAcademicCenters(formData.state); @@ -94,10 +146,9 @@ const SubscriptionPage: React.FC = () => { const fetchAcademicCenters = async (stateId: string) => { setLoadingCenters(true); try { - // Replace with your actual API endpoint - const response = await fetch(`/api/academic-centers/?stateId=${stateId}`); - const data = await response.json(); - setAcademicCenters(data || []); + const dummyData = DUMMY_ACADEMIC_CENTERS[stateId] || []; + await new Promise(resolve => setTimeout(resolve, 300)); + setAcademicCenters(dummyData); } catch (error) { console.error('Error fetching academic centers:', error); setAcademicCenters([]); @@ -107,7 +158,13 @@ const SubscriptionPage: React.FC = () => { const handleFormChange = (e: React.ChangeEvent) => { const { name, value } = e.target as HTMLInputElement; - setFormData((prev) => ({ ...prev, [name]: value })); + + if (name === 'amount') { + setAmount(value); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } + if (errors[name]) { setErrors((prev) => ({ ...prev, [name]: '' })); } @@ -116,9 +173,7 @@ const SubscriptionPage: React.FC = () => { const handleInstituteChange = (e: React.ChangeEvent<{ name?: string; value: unknown }>) => { const newSelected = e.target.value as number[]; setSelectedInstitutes(newSelected); - setAmount(newSelected.length * subscriptionAmount); - // Initialize GST fields for each selected institute const newGSTFields: GSTFieldData = {}; newSelected.forEach((id) => { if (!gstFields[id]) { @@ -169,7 +224,10 @@ const SubscriptionPage: React.FC = () => { newErrors.institute = 'Please select at least one institute'; } - // Validate GST fields + if (!amount || parseFloat(amount) <= 0) { + newErrors.amount = 'Payment amount must be greater than 0'; + } + selectedInstitutes.forEach((id) => { const gst = gstFields[id]; if (gst && gst.wantGST === 'yes') { @@ -195,19 +253,19 @@ const SubscriptionPage: React.FC = () => { setSubmitLoading(true); try { - // Prepare payment data + const stateName = DUMMY_STATES.find(s => s.id === parseInt(formData.state))?.name || formData.state; + const paymentData = { name: formData.name, email: formData.email, phone: formData.phone, - state: formData.state, - institutes: selectedInstitutes, - amount, + state: stateName, + academic_ids: selectedInstitutes, + amount: parseFloat(amount), gst_data: gstFields, }; - // Send to your payment API - const response = await fetch('/api/subscription/create-payment/', { + const response = await fetch('http://localhost:8000/api/payments/academic/session/', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -215,17 +273,21 @@ const SubscriptionPage: React.FC = () => { body: JSON.stringify(paymentData), }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create payment session'); + } + const data = await response.json(); - if (data.success) { - // Handle successful payment initiation - alert('Payment initiated successfully'); - // Redirect to payment gateway or handle response + + if (data.payment_link) { + window.location.href = data.payment_link; } else { - alert('Error: ' + (data.error || 'Unknown error')); + alert('Payment link not received. Please try again.'); } } catch (error) { console.error('Error submitting form:', error); - alert('An error occurred. Please try again.'); + alert(`Error: ${error instanceof Error ? error.message : 'An error occurred. Please try again.'}`); } setSubmitLoading(false); }; @@ -317,10 +379,9 @@ const SubscriptionPage: React.FC = () => { value={formData.state} onChange={handleFormChange as any} label="State" - startAdornment={} > -- Select State -- - {states.map((state) => ( + {DUMMY_STATES.map((state) => ( {state.name} @@ -366,10 +427,15 @@ const SubscriptionPage: React.FC = () => { , }} From b40c7fc99a44cabac98791f49d6bd0ea30445aed Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Thu, 27 Nov 2025 16:19:40 +0000 Subject: [PATCH 06/10] Additonal Institution/gst --- backend/apps/cms/migrations/0001_initial.py | 42 ++++++-------- .../apps/payments/migrations/0001_initial.py | 55 ------------------- backend/apps/payments/models.py | 7 ++- backend/apps/payments/views.py | 11 ++-- backend/config/settings.py | 2 +- 5 files changed, 30 insertions(+), 87 deletions(-) delete mode 100644 backend/apps/payments/migrations/0001_initial.py diff --git a/backend/apps/cms/migrations/0001_initial.py b/backend/apps/cms/migrations/0001_initial.py index d6aa258..ede0ade 100644 --- a/backend/apps/cms/migrations/0001_initial.py +++ b/backend/apps/cms/migrations/0001_initial.py @@ -1,40 +1,34 @@ -# Generated by Django 5.2.5 on 2025-09-16 07:34 +# Generated by Django 5.2.5 on 2025-11-24 15:52 from django.db import migrations, models class Migration(migrations.Migration): + initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="CarouselItem", + name='CarouselItem', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("carousel", models.CharField(max_length=255)), - ("image", models.ImageField(upload_to="carousel")), - ("alt_text", models.CharField(blank=True, max_length=255)), - ("caption", models.CharField(blank=True, max_length=255)), - ("link_url", models.URLField(blank=True)), - ("is_active", models.BooleanField(default=True)), - ("starts_at", models.DateTimeField(blank=True, null=True)), - ("ends_at", models.DateTimeField(blank=True, null=True)), - ("sort_order", models.PositiveIntegerField(db_index=True, default=0)), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('carousel', models.CharField(max_length=255)), + ('image', models.ImageField(upload_to='carousel')), + ('alt_text', models.CharField(blank=True, max_length=255)), + ('caption', models.CharField(blank=True, max_length=255)), + ('link_url', models.URLField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('starts_at', models.DateTimeField(blank=True, null=True)), + ('ends_at', models.DateTimeField(blank=True, null=True)), + ('sort_order', models.PositiveIntegerField(db_index=True, default=0)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), ], options={ - "ordering": ["sort_order", "created"], + 'ordering': ['sort_order', 'created'], }, ), ] diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py deleted file mode 100644 index f712de8..0000000 --- a/backend/apps/payments/migrations/0001_initial.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-24 10:08 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='HDFCTransactionDetails', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('transaction_id', models.CharField(max_length=100)), - ('order_id', models.CharField(max_length=50)), - ('requestId', models.CharField(blank=True, max_length=100, null=True)), - ('amount', models.DecimalField(decimal_places=2, max_digits=10)), - ('order_status', models.CharField(blank=True, max_length=50, null=True)), - ('udf1', models.TextField(blank=True, null=True)), - ('udf2', models.TextField(blank=True, null=True)), - ('udf3', models.TextField(blank=True, null=True)), - ('udf4', models.TextField(blank=True, null=True)), - ('udf5', models.TextField(blank=True, null=True)), - ('error_code', models.CharField(blank=True, max_length=50, null=True)), - ('error_message', models.TextField(blank=True, null=True)), - ('date_created', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='PayeeHdfcTransaction', - fields=[ - ('hdfctransactiondetails_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='payments.hdfctransactiondetails')), - ], - bases=('payments.hdfctransactiondetails',), - ), - migrations.CreateModel( - name='AcademicSubscription', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('academic_id', models.CharField(blank=True, max_length=100, null=True)), - ('phone', models.CharField(max_length=20)), - ('amount', models.DecimalField(decimal_places=2, max_digits=10)), - ('expiry_date', models.DateField()), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), - ], - ), - ] diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py index 56a7891..95a41fa 100644 --- a/backend/apps/payments/models.py +++ b/backend/apps/payments/models.py @@ -36,4 +36,9 @@ def __str__(self): class PayeeHdfcTransaction(HDFCTransactionDetails): """Separate model for ILW/Payee-based payments.""" - pass \ No newline at end of file + pass + +class AcademicCenter(models.Model): + institution_name = models.CharField(max_length=255) + academic_code = models.CharField(max_length=50) + gst_number = models.CharField(max_length=20, null=True, blank=True) diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index a9ce63d..19c5684 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -3,8 +3,7 @@ from rest_framework import status from django.conf import settings from django.http import JsonResponse -from django.shortcuts import redirect -from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction +from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction, AcademicCenter from .serializers import HDFCTransactionSerializer from .utils.hdfc_utils import generate_hashed_order_id, get_request_headers, poll_payment_status from decimal import Decimal @@ -32,10 +31,10 @@ def create_academic_payment_session(request): "udf4": data.get('state') } - # AcademicCenter lookup removed - module 'events' not available - # values = AcademicCenter.objects.filter(id__in=academic_ids).values('institution_name', 'academic_code') - # payload["udf1"] = ' ** '.join([v['institution_name'] for v in values])[:90] - # payload["udf2"] = ' ** '.join([v['academic_code'] for v in values]) + AcademicCenter lookup removed - module 'events' not available + values = AcademicCenter.objects.filter(id__in=academic_ids).values('institution_name', 'academic_code') + payload["udf1"] = ' ** '.join([v['institution_name'] for v in values])[:90] + payload["udf2"] = ' ** '.join([v['academic_code'] for v in values]) headers = get_request_headers(email) try: diff --git a/backend/config/settings.py b/backend/config/settings.py index 3b4adb1..2e479a6 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.2/ref/settings/ """ - +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. From 825b66fc02a31b0ff479d493d067daf2ffa45db7 Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Sun, 30 Nov 2025 21:25:09 +0000 Subject: [PATCH 07/10] integrated the GST invoice functionality and set up the frontend, updated the receipt page too --- .../apps/payments/migrations/0001_initial.py | 65 +++++++++++++++++++ backend/apps/payments/views.py | 10 ++- frontend/src/pages/public/PaymentStatus.tsx | 62 ++++++++++++++++++ .../src/pages/public/SubscriptionPage.tsx | 60 ++++++++++++----- 4 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 backend/apps/payments/migrations/0001_initial.py diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..ca840b7 --- /dev/null +++ b/backend/apps/payments/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated migration - Initial schema for payments app + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AcademicCenter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('institution_name', models.CharField(max_length=255)), + ('academic_code', models.CharField(max_length=50)), + ('gst_number', models.CharField(blank=True, max_length=20, null=True)), + ], + ), + migrations.CreateModel( + name='HDFCTransactionDetails', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_id', models.CharField(max_length=100)), + ('order_id', models.CharField(max_length=50)), + ('requestId', models.CharField(blank=True, max_length=100, null=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('order_status', models.CharField(blank=True, max_length=50, null=True)), + ('udf1', models.TextField(blank=True, null=True)), + ('udf2', models.TextField(blank=True, null=True)), + ('udf3', models.TextField(blank=True, null=True)), + ('udf4', models.TextField(blank=True, null=True)), + ('udf5', models.TextField(blank=True, null=True)), + ('customer_email', models.EmailField(blank=True, max_length=254, null=True)), + ('error_code', models.CharField(blank=True, max_length=50, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='PayeeHdfcTransaction', + fields=[ + ('hdfctransactiondetails_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='payments.hdfctransactiondetails')), + ], + bases=('payments.hdfctransactiondetails',), + ), + migrations.CreateModel( + name='AcademicSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('academic_id', models.CharField(blank=True, max_length=100, null=True)), + ('phone', models.CharField(max_length=20)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('expiry_date', models.DateField()), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index 19c5684..9378527 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -8,6 +8,7 @@ from .utils.hdfc_utils import generate_hashed_order_id, get_request_headers, poll_payment_status from decimal import Decimal import requests +from django.shortcuts import redirect @api_view(['POST']) def create_academic_payment_session(request): @@ -16,6 +17,7 @@ def create_academic_payment_session(request): email = data.get('email') academic_ids = data.get('academic_ids', []) amount = data.get('amount') + gst_json = data.get('gst_json', '') # GST data as JSON string payload = { "order_id": generate_hashed_order_id(email), @@ -28,10 +30,11 @@ def create_academic_payment_session(request): "return_url": request.build_absolute_uri("/api/payments/callback-handler/"), "description": "Complete Academic Subscription Payment...", "udf3": data.get('name'), - "udf4": data.get('state') + "udf4": data.get('state'), + "udf5": gst_json, # GST data for HDFC } - AcademicCenter lookup removed - module 'events' not available + # AcademicCenter lookup removed - module 'events' not available values = AcademicCenter.objects.filter(id__in=academic_ids).values('institution_name', 'academic_code') payload["udf1"] = ' ** '.join([v['institution_name'] for v in values])[:90] payload["udf2"] = ' ** '.join([v['academic_code'] for v in values]) @@ -52,7 +55,8 @@ def create_academic_payment_session(request): customer_email=email, customer_phone=data.get("phone"), udf3=data.get('name'), - udf4=data.get('state') + udf4=data.get('state'), + udf5=gst_json, # Store GST data in database ) return Response({ diff --git a/frontend/src/pages/public/PaymentStatus.tsx b/frontend/src/pages/public/PaymentStatus.tsx index 2131f80..a2613bf 100644 --- a/frontend/src/pages/public/PaymentStatus.tsx +++ b/frontend/src/pages/public/PaymentStatus.tsx @@ -17,10 +17,18 @@ interface PaymentData { udf1: string; udf2: string; udf4: string; + udf5: string; date_created: string; order_status: string; } +interface GSTInfo { + institute_id: string; + want_gst: string; + gst_number: string; + gst_name: string; +} + type PaymentStatus = "CHARGED" | "FAILED" | "PENDING" | "TIMEOUT" | "ERROR"; export default function PaymentStatus() { @@ -78,6 +86,16 @@ export default function PaymentStatus() { fetchTransactionDetails(); }, [transactionId]); + const parseGSTData = (): GSTInfo[] => { + if (!paymentData?.udf5) return []; + try { + return JSON.parse(paymentData.udf5); + } catch (error) { + console.error("Error parsing GST data:", error); + return []; + } + }; + return ( @@ -318,6 +336,50 @@ export default function PaymentStatus() { primaryTypographyProps={{ fontWeight: 600, variant: "body2" }} /> + {paymentData.udf5 && ( + <> + + + GST Invoice Information + + + {parseGSTData().map((gstItem, index) => ( + + + Institute {index + 1} + + + + + {gstItem.want_gst === 'yes' && ( + <> + + + + + + + + )} + + ))} + + )} {/* Action Buttons */} diff --git a/frontend/src/pages/public/SubscriptionPage.tsx b/frontend/src/pages/public/SubscriptionPage.tsx index 7856866..67ac376 100644 --- a/frontend/src/pages/public/SubscriptionPage.tsx +++ b/frontend/src/pages/public/SubscriptionPage.tsx @@ -38,6 +38,7 @@ interface AcademicCenter { id: number; academic_code: string; institution_name: string; + amount?: number; } interface GSTFieldData { @@ -89,32 +90,32 @@ const DUMMY_STATES = [ { id: 28, name: 'West Bengal' }, ]; -// Dummy data for academic centers per state +// Dummy data for academic centers per state with pre-populated amounts (15-20k range) const DUMMY_ACADEMIC_CENTERS: { [key: string]: AcademicCenter[] } = { '1': [ - { id: 101, academic_code: 'AP001', institution_name: 'Andhra University, Visakhapatnam' }, - { id: 102, academic_code: 'AP002', institution_name: 'Sri Venkateswara University, Tirupati' }, - { id: 103, academic_code: 'AP003', institution_name: 'Osmania University, Hyderabad' }, + { id: 101, academic_code: 'AP001', institution_name: 'Andhra University, Visakhapatnam', amount: 15000 }, + { id: 102, academic_code: 'AP002', institution_name: 'Sri Venkateswara University, Tirupati', amount: 16500 }, + { id: 103, academic_code: 'AP003', institution_name: 'Osmania University, Hyderabad', amount: 17500 }, ], '2': [ - { id: 201, academic_code: 'AR001', institution_name: 'North Eastern University, Itanagar' }, - { id: 202, academic_code: 'AR002', institution_name: 'Delhi Skill University, Arunachal Campus' }, + { id: 201, academic_code: 'AR001', institution_name: 'North Eastern University, Itanagar', amount: 15500 }, + { id: 202, academic_code: 'AR002', institution_name: 'Delhi Skill University, Arunachal Campus', amount: 18000 }, ], '3': [ - { id: 301, academic_code: 'AS001', institution_name: 'Gauhati University, Guwahati' }, - { id: 302, academic_code: 'AS002', institution_name: 'Dibrugarh University, Dibrugarh' }, - { id: 303, academic_code: 'AS003', institution_name: 'Indian Institute of Technology Guwahati' }, + { id: 301, academic_code: 'AS001', institution_name: 'Gauhati University, Guwahati', amount: 16000 }, + { id: 302, academic_code: 'AS002', institution_name: 'Dibrugarh University, Dibrugarh', amount: 17000 }, + { id: 303, academic_code: 'AS003', institution_name: 'Indian Institute of Technology Guwahati', amount: 19500 }, ], '14': [ - { id: 1401, academic_code: 'MH001', institution_name: 'University of Mumbai, Mumbai' }, - { id: 1402, academic_code: 'MH002', institution_name: 'Indian Institute of Technology Bombay' }, - { id: 1403, academic_code: 'MH003', institution_name: 'Pune University, Pune' }, - { id: 1404, academic_code: 'MH004', institution_name: 'NMIMS University, Mumbai' }, + { id: 1401, academic_code: 'MH001', institution_name: 'University of Mumbai, Mumbai', amount: 18500 }, + { id: 1402, academic_code: 'MH002', institution_name: 'Indian Institute of Technology Bombay', amount: 20000 }, + { id: 1403, academic_code: 'MH003', institution_name: 'Pune University, Pune', amount: 17000 }, + { id: 1404, academic_code: 'MH004', institution_name: 'NMIMS University, Mumbai', amount: 19000 }, ], '26': [ - { id: 2601, academic_code: 'UP001', institution_name: 'University of Lucknow, Lucknow' }, - { id: 2602, academic_code: 'UP002', institution_name: 'Indian Institute of Technology BHU, Varanasi' }, - { id: 2603, academic_code: 'UP003', institution_name: 'Aligarh Muslim University, Aligarh' }, + { id: 2601, academic_code: 'UP001', institution_name: 'University of Lucknow, Lucknow', amount: 15500 }, + { id: 2602, academic_code: 'UP002', institution_name: 'Indian Institute of Technology BHU, Varanasi', amount: 19500 }, + { id: 2603, academic_code: 'UP003', institution_name: 'Aligarh Muslim University, Aligarh', amount: 16500 }, ], }; @@ -174,6 +175,19 @@ const SubscriptionPage: React.FC = () => { const newSelected = e.target.value as number[]; setSelectedInstitutes(newSelected); + // Calculate total amount from selected institutes + let totalAmount = 0; + newSelected.forEach((id) => { + const institute = academicCenters.find((c) => c.id === id); + if (institute && institute.amount) { + totalAmount += institute.amount; + } + }); + + // Auto-populate amount field + setAmount(totalAmount > 0 ? totalAmount.toString() : ''); + + // Initialize GST fields for selected institutes const newGSTFields: GSTFieldData = {}; newSelected.forEach((id) => { if (!gstFields[id]) { @@ -255,6 +269,14 @@ const SubscriptionPage: React.FC = () => { try { const stateName = DUMMY_STATES.find(s => s.id === parseInt(formData.state))?.name || formData.state; + // Prepare GST data as JSON string for udf5 + const gstDataForPayment = Object.entries(gstFields).map(([instituteId, gstInfo]) => ({ + institute_id: instituteId, + want_gst: gstInfo.wantGST, + gst_number: gstInfo.gstNumber, + gst_name: gstInfo.gstName, + })); + const paymentData = { name: formData.name, email: formData.email, @@ -263,6 +285,7 @@ const SubscriptionPage: React.FC = () => { academic_ids: selectedInstitutes, amount: parseFloat(amount), gst_data: gstFields, + gst_json: JSON.stringify(gstDataForPayment), // For HDFC API udf5 field }; const response = await fetch('http://localhost:8000/api/payments/academic/session/', { @@ -432,9 +455,10 @@ const SubscriptionPage: React.FC = () => { value={amount} onChange={handleFormChange} error={!!errors.amount} - helperText={errors.amount || 'Enter the payment amount in rupees'} + helperText={errors.amount || 'Auto-calculated based on selected institutes'} type="number" - placeholder="Enter amount" + placeholder="Select institutes first" + disabled inputProps={{ step: '1', min: '0' }} InputProps={{ startAdornment: , From e0cb036f1bf326794a5c09a3929d7a1833e64318 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Fri, 5 Dec 2025 18:20:53 +0000 Subject: [PATCH 08/10] AcademicSubscriptionDetail/payment_callback --- backend/apps/payments/admin.py | 15 +- .../apps/payments/migrations/0001_initial.py | 36 +++- ...2_hdfctransactiondetails_customer_phone.py | 18 -- ...3_hdfctransactiondetails_customer_email.py | 18 -- backend/apps/payments/models.py | 28 +++- backend/apps/payments/urls.py | 1 + backend/apps/payments/views.py | 154 +++++++++++++++++- 7 files changed, 221 insertions(+), 49 deletions(-) delete mode 100644 backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py delete mode 100644 backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py diff --git a/backend/apps/payments/admin.py b/backend/apps/payments/admin.py index 06dbe1e..69eb98c 100644 --- a/backend/apps/payments/admin.py +++ b/backend/apps/payments/admin.py @@ -1,15 +1,24 @@ -# Register your models here. from django.contrib import admin -from .models import HDFCTransactionDetails, AcademicSubscription, PayeeHdfcTransaction +from .models import HDFCTransactionDetails, AcademicSubscription, PayeeHdfcTransaction, AcademicSubscriptionDetail @admin.register(HDFCTransactionDetails) class HDFCTransactionAdmin(admin.ModelAdmin): list_display = ('order_id', 'transaction_id', 'order_status', 'amount', 'date_created') + search_fields = ('order_id', 'transaction_id', 'customer_email') + @admin.register(AcademicSubscription) class AcademicSubscriptionAdmin(admin.ModelAdmin): list_display = ('user', 'academic_id', 'amount', 'expiry_date') + search_fields = ('user__email', 'academic_id') + @admin.register(PayeeHdfcTransaction) class PayeeHdfcTransactionAdmin(admin.ModelAdmin): - list_display = ('order_id', 'transaction_id', 'order_status', 'amount') \ No newline at end of file + list_display = ('order_id', 'transaction_id', 'order_status', 'amount') + + +@admin.register(AcademicSubscriptionDetail) +class AcademicSubscriptionDetailAdmin(admin.ModelAdmin): + list_display = ('subscription', 'start_date', 'end_date', 'is_active', 'created_at') + search_fields = ('subscription__user__email', 'subscription__academic_id') diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py index ca840b7..f991b26 100644 --- a/backend/apps/payments/migrations/0001_initial.py +++ b/backend/apps/payments/migrations/0001_initial.py @@ -3,7 +3,8 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion - +# Generated by Django 5.2.5 on 2025-12-05 17:50 +import django.utils.timezone class Migration(migrations.Migration): @@ -38,12 +39,17 @@ class Migration(migrations.Migration): ('udf4', models.TextField(blank=True, null=True)), ('udf5', models.TextField(blank=True, null=True)), ('customer_email', models.EmailField(blank=True, max_length=254, null=True)), +<<<<<<< HEAD +======= + ('customer_phone', models.CharField(blank=True, max_length=20, null=True)), +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) ('error_code', models.CharField(blank=True, max_length=50, null=True)), ('error_message', models.TextField(blank=True, null=True)), ('date_created', models.DateTimeField(auto_now_add=True)), ], ), migrations.CreateModel( +<<<<<<< HEAD name='PayeeHdfcTransaction', fields=[ ('hdfctransactiondetails_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='payments.hdfctransactiondetails')), @@ -51,6 +57,8 @@ class Migration(migrations.Migration): bases=('payments.hdfctransactiondetails',), ), migrations.CreateModel( +======= +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) name='AcademicSubscription', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), @@ -58,8 +66,34 @@ class Migration(migrations.Migration): ('phone', models.CharField(max_length=20)), ('amount', models.DecimalField(decimal_places=2, max_digits=10)), ('expiry_date', models.DateField()), +<<<<<<< HEAD ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), +======= + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), + ], + ), + migrations.CreateModel( + name='AcademicSubscriptionDetail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_date', models.DateField(default=django.utils.timezone.now)), + ('end_date', models.DateField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('note', models.TextField(blank=True, null=True)), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history', to='payments.academicsubscription')), + ], + ), + migrations.CreateModel( + name='PayeeHdfcTransaction', + fields=[ + ('hdfctransactiondetails_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='payments.hdfctransactiondetails')), + ], + bases=('payments.hdfctransactiondetails',), + ), +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) ] diff --git a/backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py b/backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py deleted file mode 100644 index 3606111..0000000 --- a/backend/apps/payments/migrations/0002_hdfctransactiondetails_customer_phone.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-24 17:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='hdfctransactiondetails', - name='customer_phone', - field=models.CharField(blank=True, max_length=20, null=True), - ), - ] diff --git a/backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py b/backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py deleted file mode 100644 index af8f2be..0000000 --- a/backend/apps/payments/migrations/0003_hdfctransactiondetails_customer_email.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-24 18:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0002_hdfctransactiondetails_customer_phone'), - ] - - operations = [ - migrations.AddField( - model_name='hdfctransactiondetails', - name='customer_email', - field=models.EmailField(blank=True, max_length=254, null=True), - ), - ] diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py index 95a41fa..7b33094 100644 --- a/backend/apps/payments/models.py +++ b/backend/apps/payments/models.py @@ -1,5 +1,6 @@ from django.db import models from django.conf import settings +from django.utils import timezone class HDFCTransactionDetails(models.Model): transaction_id = models.CharField(max_length=100) @@ -31,14 +32,39 @@ class AcademicSubscription(models.Model): expiry_date = models.DateField() def __str__(self): - return f"Subscription: {self.user.email} - {self.academic_id}" + # use email if available (user.email may not exist for some custom user models) + try: + user_email = self.user.email + except Exception: + user_email = str(self.user) + return f"Subscription: {user_email} - {self.academic_id}" + + +class AcademicSubscriptionDetail(models.Model): + """ + History of subscription activations / changes. + Created so reviewers' comment about AcademicSubscriptionDetail is satisfied. + """ + subscription = models.ForeignKey(AcademicSubscription, on_delete=models.CASCADE, related_name='history') + start_date = models.DateField(default=timezone.now) + end_date = models.DateField(null=True, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + note = models.TextField(null=True, blank=True) + + def __str__(self): + return f"SubscriptionDetail: {self.subscription} active={self.is_active} from={self.start_date}" class PayeeHdfcTransaction(HDFCTransactionDetails): """Separate model for ILW/Payee-based payments.""" pass + class AcademicCenter(models.Model): institution_name = models.CharField(max_length=255) academic_code = models.CharField(max_length=50) gst_number = models.CharField(max_length=20, null=True, blank=True) + + def __str__(self): + return f"{self.institution_name} ({self.academic_code})" diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py index 33440d5..73f534b 100644 --- a/backend/apps/payments/urls.py +++ b/backend/apps/payments/urls.py @@ -7,4 +7,5 @@ path('transaction//', views.get_transaction_details, name='get-transaction-details'), path('callback-handler/', views.callback_handler, name='callback-handler'), path('payment/callback/', views.payment_callback, name='payment-callback'), + path('academic/session/', views.create_academic_payment_session, name='create-academic-session'), ] \ No newline at end of file diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index 9378527..f1cba03 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -3,12 +3,16 @@ from rest_framework import status from django.conf import settings from django.http import JsonResponse -from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction, AcademicCenter +from .models import AcademicSubscription, HDFCTransactionDetails, PayeeHdfcTransaction, AcademicCenter, AcademicSubscriptionDetail from .serializers import HDFCTransactionSerializer from .utils.hdfc_utils import generate_hashed_order_id, get_request_headers, poll_payment_status from decimal import Decimal import requests from django.shortcuts import redirect +from django.utils import timezone +from django.contrib.auth import get_user_model + +User = get_user_model() @api_view(['POST']) def create_academic_payment_session(request): @@ -34,7 +38,11 @@ def create_academic_payment_session(request): "udf5": gst_json, # GST data for HDFC } +<<<<<<< HEAD # AcademicCenter lookup removed - module 'events' not available +======= + #AcademicCenter lookup removed - module 'events' not available +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) values = AcademicCenter.objects.filter(id__in=academic_ids).values('institution_name', 'academic_code') payload["udf1"] = ' ** '.join([v['institution_name'] for v in values])[:90] payload["udf2"] = ' ** '.join([v['academic_code'] for v in values]) @@ -96,16 +104,146 @@ def get_transaction_details(request, transaction_id): status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - @api_view(['POST']) def payment_callback(request): - data = request.data - - # validate checksum here - # verify amount/order_id - # update transaction model + """ + Endpoint called by HDFC (or client) to post payment result. + - Verifies HMAC/signature when present + - Updates HDFCTransactionDetails record + - On success (CHARGED), attempt to create AcademicSubscription & AcademicSubscriptionDetail + """ + raw = request.data or {} + # Prepare params in the form expected by verify_hmac_signature: + # the util expects values as lists (signature access uses [0]), so convert single values -> list + params_for_verify = {k: (v if isinstance(v, list) else [v]) for k, v in raw.items()} + + # signature verification (if signature present in payload). If verification fails, we still log but mark transaction. + signature_ok = True + try: + if 'signature' in params_for_verify: + signature_ok = verify_hmac_signature(params_for_verify) + except Exception: + signature_ok = False + + # Extract identifying fields - HDFC may send transaction id, order id or requestId + transaction_id = raw.get('transaction_id') or raw.get('id') or raw.get('txnId') or raw.get('txn_id') or raw.get('transactionId') + order_id = raw.get('order_id') or raw.get('orderId') or raw.get('orderid') + request_id = raw.get('requestId') or raw.get('request_id') + + amount = raw.get('amount') or raw.get('txn_amount') or raw.get('amount_paid') or raw.get('txAmount') + status_field = raw.get('status') or raw.get('order_status') or raw.get('orderStatus') or raw.get('statusCode') + + # Try to find existing transaction record by order_id or transaction_id or requestId + transaction = None + try: + if order_id: + transaction = HDFCTransactionDetails.objects.filter(order_id=order_id).first() + if not transaction and transaction_id: + transaction = HDFCTransactionDetails.objects.filter(transaction_id=transaction_id).first() + if not transaction and request_id: + transaction = HDFCTransactionDetails.objects.filter(requestId=request_id).first() + except Exception as e: + # unexpected DB issue; return 500 + return Response({"error": "db_error", "message": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # If no transaction found, create a minimal record (so we have a DB row to inspect) + if not transaction: + try: + transaction = HDFCTransactionDetails.objects.create( + transaction_id=transaction_id or generate_hashed_order_id("unknown"), + order_id=order_id or f"ORDER_{timezone.now().timestamp()}", + requestId=request_id, + amount=Decimal(amount) if amount else Decimal("0.00"), + order_status=status_field or ("UNKNOWN" if not signature_ok else "PENDING"), + customer_email=raw.get('customer_email') or raw.get('email'), + customer_phone=raw.get('customer_phone') or raw.get('phone'), + udf1=raw.get('udf1'), + udf2=raw.get('udf2'), + udf3=raw.get('udf3'), + udf4=raw.get('udf4'), + udf5=raw.get('udf5'), + error_message=None if signature_ok else "signature_verification_failed", + ) + except Exception as e: + return Response({"error": "create_transaction_failed", "message": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Update transaction details from payload where present + try: + if amount: + # safe cast + try: + transaction.amount = Decimal(str(amount)) + except Exception: + pass + if status_field: + transaction.order_status = status_field + if raw.get('error_code'): + transaction.error_code = raw.get('error_code') + if raw.get('error_message'): + transaction.error_message = raw.get('error_message') + # update customer fields + transaction.customer_email = raw.get('customer_email') or raw.get('email') or transaction.customer_email + transaction.customer_phone = raw.get('customer_phone') or raw.get('phone') or transaction.customer_phone + # store requestId if provided + if request_id: + transaction.requestId = request_id + transaction.save() + except Exception as e: + return Response({"error": "update_failed", "message": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # If payment successful, attempt to create subscription record(s) + # Common HDFC success state name used in your utils: 'CHARGED' + success_states = {"CHARGED", "SUCCESS", "COMPLETED", "OK"} + if str(transaction.order_status).upper() in success_states: + # parse academic id(s) from udf2 or udf1 or custom field. Adjust as per your payload contract + academic_id = None + # udf2 in create_academic_payment_session was academic_code joined + if transaction.udf2: + academic_id = transaction.udf2.split(' ** ')[0] if isinstance(transaction.udf2, str) else transaction.udf2 + + user = None + email = transaction.customer_email + if email: + try: + user = User.objects.filter(email__iexact=email).first() + except Exception: + user = None + + # Only create a subscription if we can find a user (db integrity requires user) + if user: + try: + # expiry: default 1 year from now if not provided in any payload + expiry_date = None + if raw.get('expiry_date'): + expiry_date = raw.get('expiry_date') + else: + expiry_date = (timezone.now().date().replace(day=1) + timezone.timedelta(days=365)) + + # Create or update subscription; simplistic approach: always create a new subscription row + subscription = AcademicSubscription.objects.create( + user=user, + academic_id=academic_id, + transaction=transaction, + phone=transaction.customer_phone or "", + amount=transaction.amount or Decimal('0.00'), + expiry_date=expiry_date + ) + + # Add a subscription detail / history record + AcademicSubscriptionDetail.objects.create( + subscription=subscription, + start_date=timezone.now().date(), + end_date=subscription.expiry_date, + is_active=True, + note=f"Created from HDFC callback. txn={transaction.transaction_id}" + ) + except Exception as e: + # don't fail the whole callback if subscription creation fails; log the error in transaction + transaction.error_message = (transaction.error_message or "") + f" | subscription_creation_failed: {str(e)}" + transaction.save() - return Response({"status": "received"}) + # Successful receipt acknowledgement for gateway + return Response({"status": "received", "verified": signature_ok}) @api_view(['GET', 'POST']) From 358b3de01b3c8a4244016c7a4e26cbcb2a4d157f Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Fri, 5 Dec 2025 18:07:15 +0000 Subject: [PATCH 09/10] AcademicSubscriptionDetail/payment_callback --- .../apps/payments/migrations/0001_initial.py | 23 +++++++++++++++++++ backend/apps/payments/views.py | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py index f991b26..aa66f5e 100644 --- a/backend/apps/payments/migrations/0001_initial.py +++ b/backend/apps/payments/migrations/0001_initial.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Generated migration - Initial schema for payments app from django.conf import settings @@ -5,6 +6,15 @@ import django.db.models.deletion # Generated by Django 5.2.5 on 2025-12-05 17:50 import django.utils.timezone +======= +# Generated by Django 5.2.5 on 2025-12-05 17:50 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) class Migration(migrations.Migration): @@ -40,6 +50,10 @@ class Migration(migrations.Migration): ('udf5', models.TextField(blank=True, null=True)), ('customer_email', models.EmailField(blank=True, max_length=254, null=True)), <<<<<<< HEAD +<<<<<<< HEAD +======= + ('customer_phone', models.CharField(blank=True, max_length=20, null=True)), +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) ======= ('customer_phone', models.CharField(blank=True, max_length=20, null=True)), >>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) @@ -49,6 +63,7 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( +<<<<<<< HEAD <<<<<<< HEAD name='PayeeHdfcTransaction', fields=[ @@ -58,6 +73,8 @@ class Migration(migrations.Migration): ), migrations.CreateModel( ======= +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) +======= >>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) name='AcademicSubscription', fields=[ @@ -66,12 +83,15 @@ class Migration(migrations.Migration): ('phone', models.CharField(max_length=20)), ('amount', models.DecimalField(decimal_places=2, max_digits=10)), ('expiry_date', models.DateField()), +<<<<<<< HEAD <<<<<<< HEAD ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), ======= +======= +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='payments.hdfctransactiondetails')), ], @@ -95,5 +115,8 @@ class Migration(migrations.Migration): ], bases=('payments.hdfctransactiondetails',), ), +<<<<<<< HEAD +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) +======= >>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) ] diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index f1cba03..297b2d1 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -38,8 +38,12 @@ def create_academic_payment_session(request): "udf5": gst_json, # GST data for HDFC } +<<<<<<< HEAD <<<<<<< HEAD # AcademicCenter lookup removed - module 'events' not available +======= + #AcademicCenter lookup removed - module 'events' not available +>>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) ======= #AcademicCenter lookup removed - module 'events' not available >>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) From 8aa790ae795589ebace8a2874db6dd85c2eb8561 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Mon, 8 Dec 2025 07:49:55 +0000 Subject: [PATCH 10/10] Script --- .../apps/payments/migrations/0001_initial.py | 10 -- backend/apps/payments/views.py | 9 +- backend/python_script.py | 95 +++++++++++++++++++ 3 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 backend/python_script.py diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py index aa66f5e..67c0483 100644 --- a/backend/apps/payments/migrations/0001_initial.py +++ b/backend/apps/payments/migrations/0001_initial.py @@ -1,12 +1,3 @@ -<<<<<<< HEAD -# Generated migration - Initial schema for payments app - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -# Generated by Django 5.2.5 on 2025-12-05 17:50 -import django.utils.timezone -======= # Generated by Django 5.2.5 on 2025-12-05 17:50 import django.db.models.deletion @@ -14,7 +5,6 @@ from django.conf import settings from django.db import migrations, models ->>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) class Migration(migrations.Migration): diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index 297b2d1..b90daf0 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -38,15 +38,8 @@ def create_academic_payment_session(request): "udf5": gst_json, # GST data for HDFC } -<<<<<<< HEAD -<<<<<<< HEAD - # AcademicCenter lookup removed - module 'events' not available -======= #AcademicCenter lookup removed - module 'events' not available ->>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) -======= - #AcademicCenter lookup removed - module 'events' not available ->>>>>>> 70ebc1e (AcademicSubscriptionDetail/payment_callback) + (AcademicSubscriptionDetail/payment_callback) values = AcademicCenter.objects.filter(id__in=academic_ids).values('institution_name', 'academic_code') payload["udf1"] = ' ** '.join([v['institution_name'] for v in values])[:90] payload["udf2"] = ' ** '.join([v['academic_code'] for v in values]) diff --git a/backend/python_script.py b/backend/python_script.py new file mode 100644 index 0000000..bf0e753 --- /dev/null +++ b/backend/python_script.py @@ -0,0 +1,95 @@ +import requests +import csv +from datetime import datetime +import time + +INPUT_FILE = "foss-lang.csv" +OUTPUT_FILE = "request_results.csv" + +# Limit number of output rows (you can change this!) +counter = 50 + +# Spoken Tutorial search URL +BASE_URL = "https://beta.spoken-tutorial.org/tutorial-search/" + + +def make_request(url, params): + timestamp = datetime.now().isoformat() + try: + res = requests.get(url, params=params, timeout=10) + elapsed = res.elapsed.total_seconds() + status = res.status_code + return elapsed, status, timestamp + except: + return None, None, timestamp + + +def main(): + with open(INPUT_FILE, "r") as infile, open(OUTPUT_FILE, "w", newline="") as outfile: + reader = csv.DictReader(infile) + writer = csv.writer(outfile) + + # CSV header + writer.writerow([ + "foss", + "language", + "url", + "elapsed1", + "elapsed2", + "status_code", + "timestamp" + ]) + + count = 0 + + for row in reader: + + if count >= counter: + break + + foss = row["foss"].strip() + language = row["language"].strip() + + # Replace spaces with "+" for URL + foss_url = foss.replace(" ", "+") + + # Build URL correctly + final_url = ( + f"{BASE_URL}?search_foss={foss_url}" + f"&search_language={language}" + ) + + params = { + "search_foss": foss, + "search_language": language + } + + # 1st request + elapsed1, status1, ts1 = make_request(BASE_URL, params) + + time.sleep(1) + + # 2nd request + elapsed2, status2, ts2 = make_request(BASE_URL, params) + + final_status = status2 if status2 is not None else status1 + final_timestamp = ts2 + + writer.writerow([ + foss, + language, + final_url, + elapsed1, + elapsed2, + final_status, + final_timestamp + ]) + + print(f"Done → {foss}-{language}: {elapsed1}s, {elapsed2}s") + count += 1 + + print("\nCSV created:", OUTPUT_FILE) + + +if __name__ == "__main__": + main()