diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 29253b3..abf3682 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index bfd74de..0905d2c 100644 --- a/config/settings.py +++ b/config/settings.py @@ -213,4 +213,8 @@ MESSAGE_TAGS = { messages.SUCCESS: 'success', messages.WARNING: 'warning', messages.ERROR: 'danger', -} \ No newline at end of file +} +# Thawani Payment Gateway Configuration +THAWANI_SECRET_KEY = os.getenv('THAWANI_SECRET_KEY', 'rRQ26GcsZ60u9YREs9GfWfE9p99e91') +THAWANI_PUBLISHABLE_KEY = os.getenv('THAWANI_PUBLISHABLE_KEY', 'HGv7H6h09Yjt99v69XatjtS80Ym669') +THAWANI_BASE_URL = os.getenv('THAWANI_BASE_URL', 'https://uatcheckout.thawani.om') diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index ca66217..4611a8f 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/thawani.cpython-311.pyc b/core/__pycache__/thawani.cpython-311.pyc new file mode 100644 index 0000000..0c035ac Binary files /dev/null and b/core/__pycache__/thawani.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index c3f9b3a..354b745 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index a0cf697..c73e35d 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/migrations/0020_transaction_payment_status_transaction_session_id.py b/core/migrations/0020_transaction_payment_status_transaction_session_id.py new file mode 100644 index 0000000..0eb5899 --- /dev/null +++ b/core/migrations/0020_transaction_payment_status_transaction_session_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-24 06:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_transaction'), + ] + + operations = [ + migrations.AddField( + model_name='transaction', + name='payment_status', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Payment Status'), + ), + migrations.AddField( + model_name='transaction', + name='session_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Session ID'), + ), + ] diff --git a/core/migrations/__pycache__/0020_transaction_payment_status_transaction_session_id.cpython-311.pyc b/core/migrations/__pycache__/0020_transaction_payment_status_transaction_session_id.cpython-311.pyc new file mode 100644 index 0000000..dfc6654 Binary files /dev/null and b/core/migrations/__pycache__/0020_transaction_payment_status_transaction_session_id.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 08412f8..ce891d1 100644 --- a/core/models.py +++ b/core/models.py @@ -400,6 +400,8 @@ class Transaction(models.Model): payment_method = models.CharField(_('Payment Method'), max_length=100, blank=True) reference_number = models.CharField(_('Reference Number'), max_length=100, blank=True) receipt_number = models.CharField(_('Receipt Number'), max_length=20, unique=True, blank=True) + session_id = models.CharField(_("Session ID"), max_length=255, blank=True, null=True) + payment_status = models.CharField(_("Payment Status"), max_length=50, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/core/thawani.py b/core/thawani.py new file mode 100644 index 0000000..fce1a15 --- /dev/null +++ b/core/thawani.py @@ -0,0 +1,50 @@ +import requests +import logging +from django.conf import settings + +logger = logging.getLogger(__name__) + +class ThawaniClient: + def __init__(self): + self.secret_key = getattr(settings, 'THAWANI_SECRET_KEY', '') + self.publishable_key = getattr(settings, 'THAWANI_PUBLISHABLE_KEY', '') + self.base_url = getattr(settings, 'THAWANI_BASE_URL', 'https://uatcheckout.thawani.om') + self.headers = { + 'thawani-api-key': self.secret_key, + 'Content-Type': 'application/json' + } + + def create_checkout_session(self, payload): + """ + Create a checkout session with Thawani. + Payload should include: client_reference_id, products, success_url, cancel_url, metadata. + """ + url = f"{self.base_url}/api/v1/checkout/session" + try: + response = requests.post(url, json=payload, headers=self.headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Thawani session creation failed: {e}") + if hasattr(e, 'response') and e.response: + logger.error(f"Thawani error response: {e.response.text}") + return None + + def get_checkout_session(self, session_id): + """ + Retrieve a checkout session to check its status. + """ + url = f"{self.base_url}/api/v1/checkout/session/{session_id}" + try: + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Thawani session retrieval failed: {e}") + return None + + def get_payment_url(self, session_id): + """ + Construct the payment URL to redirect the user. + """ + return f"{self.base_url}/pay/{session_id}?key={self.publishable_key}" diff --git a/core/urls.py b/core/urls.py index 471f4cf..28f8bc3 100644 --- a/core/urls.py +++ b/core/urls.py @@ -24,6 +24,9 @@ urlpatterns = [ path("terms-of-service/", views.terms_of_service, name="terms_of_service"), path("subscription-expired/", views.subscription_expired, name="subscription_expired"), path("subscription-renew/", views.renew_subscription, name="renew_subscription"), + path("payment/success/", views.thawani_success, name="thawani_success"), + path("payment/cancel/", views.thawani_cancel, name="thawani_cancel"), + path("payment/webhook/", views.thawani_webhook, name="thawani_webhook"), path("financial-history/", views.financial_history, name="financial_history"), path("receipt//", views.transaction_receipt, name="transaction_receipt"), path("admin/financials/", views.admin_financials, name="admin_financials"), diff --git a/core/views.py b/core/views.py index 43eb66c..30a464b 100644 --- a/core/views.py +++ b/core/views.py @@ -1,8 +1,10 @@ +from .thawani import ThawaniClient from datetime import timedelta from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.contrib.auth import login, authenticate, logout from django.utils import timezone +from django.urls import reverse from .models import Profile, Truck, Shipment, Bid, Message, OTPCode, Country, City, AppSetting, Banner, HomeSection, Transaction from .forms import ( TruckForm, ShipmentForm, BidForm, UserRegistrationForm, @@ -16,6 +18,8 @@ from .whatsapp import send_whatsapp_message from django.contrib.auth.forms import AuthenticationForm from django.core.mail import send_mail from django.conf import settings +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse import json def home(request): @@ -108,17 +112,15 @@ def verify_otp_registration(request): profile.phone_number = registration_data['phone_number'] profile.country_code = registration_data['country_code'] profile.subscription_plan = registration_data.get('subscription_plan', 'NONE') - if profile.subscription_plan != 'NONE': - profile.is_subscription_active = True - if profile.subscription_plan == 'MONTHLY': - profile.subscription_expiry = timezone.now().date() + timedelta(days=30) - elif profile.subscription_plan == 'ANNUAL': - profile.subscription_expiry = timezone.now().date() + timedelta(days=365) profile.save() login(request, user) if 'registration_data' in request.session: del request.session['registration_data'] + + if profile.subscription_plan != 'NONE': + return thawani_checkout(request, profile.subscription_plan) + messages.success(request, _("Registration successful. Welcome!")) return redirect('dashboard') else: @@ -486,17 +488,87 @@ def renew_subscription(request): if request.method == 'POST': form = RenewSubscriptionForm(request.POST) if form.is_valid(): - profile = request.user.profile plan = form.cleaned_data['subscription_plan'] - - # Calculate amount based on role and plan - app_settings = AppSetting.objects.first() - amount = 0 - if profile.role == 'SHIPPER': - amount = app_settings.shipper_monthly_fee if plan == 'MONTHLY' else app_settings.shipper_annual_fee - elif profile.role == 'TRUCK_OWNER': - amount = app_settings.truck_owner_monthly_fee if plan == 'MONTHLY' else app_settings.truck_owner_annual_fee + return thawani_checkout(request, plan) + return redirect('subscription_expired') +@login_required +def thawani_checkout(request, plan): + profile = request.user.profile + app_settings = AppSetting.objects.first() + + amount = 0 + if profile.role == 'SHIPPER': + amount = app_settings.shipper_monthly_fee if plan == 'MONTHLY' else app_settings.shipper_annual_fee + elif profile.role == 'TRUCK_OWNER': + amount = app_settings.truck_owner_monthly_fee if plan == 'MONTHLY' else app_settings.truck_owner_annual_fee + + # Thawani expects amount in baisa (1 OMR = 1000 baisa) + amount_in_baisa = int(amount * 1000) + + client = ThawaniClient() + + # Generate a unique reference + transaction = Transaction.objects.create( + user=request.user, + amount=amount, + transaction_type='PAYMENT', + status='PENDING', + description=f"Subscription: {plan}", + payment_method="Thawani" + ) + + payload = { + "client_reference_id": transaction.receipt_number, + "products": [ + { + "name": f"MASAR CARGO {plan} Subscription", + "unit_amount": amount_in_baisa, + "quantity": 1 + } + ], + "success_url": request.build_absolute_uri(reverse('thawani_success')), + "cancel_url": request.build_absolute_uri(reverse('thawani_cancel')), + "metadata": { + "plan": plan, + "user_id": request.user.id, + "transaction_id": transaction.id + } + } + + session = client.create_checkout_session(payload) + if session and session.get('success'): + session_id = session['data']['session_id'] + transaction.session_id = session_id + transaction.save() + return redirect(client.get_payment_url(session_id)) + else: + messages.error(request, _("Failed to initiate payment. Please try again later.")) + return redirect('dashboard') + +@login_required +def thawani_success(request): + session_id = request.GET.get('session_id') + if not session_id: + # Fallback to looking for the last pending transaction for this user + transaction = Transaction.objects.filter(user=request.user, status='PENDING', payment_method='Thawani').first() + else: + transaction = get_object_or_404(Transaction, session_id=session_id, user=request.user) + + client = ThawaniClient() + session_data = client.get_checkout_session(transaction.session_id) + + if session_data and session_data.get('success'): + payment_status = session_data['data']['payment_status'] + transaction.payment_status = payment_status + + if payment_status == 'paid': + transaction.status = 'COMPLETED' + transaction.save() + + # Activate Subscription + profile = transaction.user.profile + plan = session_data['data']['metadata'].get('plan', profile.subscription_plan) profile.subscription_plan = plan profile.is_subscription_active = True @@ -507,37 +579,77 @@ def renew_subscription(request): profile.save() - # Create Transaction record - Transaction.objects.create( - user=request.user, - amount=amount, - transaction_type='PAYMENT', - status='COMPLETED', - description=f"Subscription Renewal: {plan}", - payment_method="Online Payment" - ) - # Notifications expiry_date = profile.subscription_expiry.strftime('%Y-%m-%d') msg = _("Your subscription for MASAR CARGO has been successfully renewed! Your new expiry date is %(date)s. Thank you for using our service.") % {"date": expiry_date} - # WhatsApp if profile.full_phone_number: send_whatsapp_message(profile.full_phone_number, msg) - # Email - if request.user.email: + if transaction.user.email: send_mail( - _("Subscription Renewed - MASAR CARGO"), + _("Subscription Activated - MASAR CARGO"), msg, settings.DEFAULT_FROM_EMAIL, - [request.user.email], + [transaction.user.email], fail_silently=True, ) - - messages.success(request, _("Subscription renewed successfully!")) + + messages.success(request, _("Payment successful! Your subscription is now active.")) return redirect('dashboard') - return redirect('subscription_expired') + else: + transaction.status = 'FAILED' + transaction.save() + messages.error(request, _("Payment was not successful. Status: %(status)s") % {'status': payment_status}) + else: + messages.error(request, _("Failed to verify payment status.")) + + return redirect('dashboard') + +@login_required +def thawani_cancel(request): + messages.warning(request, _("Payment was cancelled.")) + return redirect('dashboard') + +@csrf_exempt +def thawani_webhook(request): + """ + Handle asynchronous status updates from Thawani. + """ + if request.method == 'POST': + try: + data = json.loads(request.body) + event_type = data.get('event_type') + + if event_type == 'checkout.completed': + session_id = data['data']['session_id'] + payment_status = data['data']['payment_status'] + + transaction = Transaction.objects.filter(session_id=session_id).first() + if transaction and transaction.status == 'PENDING': + transaction.payment_status = payment_status + if payment_status == 'paid': + transaction.status = 'COMPLETED' + transaction.save() + + # Activate Subscription + profile = transaction.user.profile + plan = data['data']['metadata'].get('plan', profile.subscription_plan) + profile.subscription_plan = plan + profile.is_subscription_active = True + if plan == 'MONTHLY': + profile.subscription_expiry = timezone.now().date() + timedelta(days=30) + elif plan == 'ANNUAL': + profile.subscription_expiry = timezone.now().date() + timedelta(days=365) + profile.save() + else: + transaction.status = 'FAILED' + transaction.save() + + return HttpResponse(status=200) + except Exception as e: + return HttpResponse(status=400) + return HttpResponse(status=405) @login_required def financial_history(request): @@ -602,4 +714,4 @@ def admin_app_settings(request): else: form = AppSettingForm(instance=settings_obj) - return render(request, 'core/app_settings.html', {'form': form}) \ No newline at end of file + return render(request, 'core/app_settings.html', {'form': form})