diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 2aa9bed..aaa84ed 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 abd7cb1..c276bb9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -185,6 +185,23 @@ CONTACT_EMAIL_TO = [ # When both TLS and SSL flags are enabled, prefer SSL explicitly if EMAIL_USE_SSL: EMAIL_USE_TLS = False + +# Thawani Payment Settings +THAWANI_API_KEY = os.getenv("THAWANI_API_KEY", "rRQ26GcsZ60u9YCD9As60reHscS3Jt") # Placeholder Test Key +THAWANI_PUBLISHABLE_KEY = os.getenv("THAWANI_PUBLISHABLE_KEY", "HGvTMLsnssOfssSshvSOfssOfsSshv") # Placeholder +THAWANI_MODE = os.getenv("THAWANI_MODE", "test") # 'test' or 'live' + +if THAWANI_MODE == 'live': + THAWANI_API_URL = "https://checkout.thawani.om/api/v1" +else: + THAWANI_API_URL = "https://uatcheckout.thawani.om/api/v1" + +# WhatsApp Notification Settings +WHATSAPP_API_KEY = os.getenv("WHATSAPP_API_KEY", "") +WHATSAPP_PHONE_ID = os.getenv("WHATSAPP_PHONE_ID", "") +WHATSAPP_BUSINESS_ACCOUNT_ID = os.getenv("WHATSAPP_BUSINESS_ACCOUNT_ID", "") +WHATSAPP_ENABLED = os.getenv("WHATSAPP_ENABLED", "false").lower() == "true" + # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 64e66ae..becc875 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 899df19..7b76646 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/payment_utils.cpython-311.pyc b/core/__pycache__/payment_utils.cpython-311.pyc new file mode 100644 index 0000000..e98bfe9 Binary files /dev/null and b/core/__pycache__/payment_utils.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 125f3bd..9d3522b 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 a02e5b3..4510127 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/__pycache__/whatsapp_utils.cpython-311.pyc b/core/__pycache__/whatsapp_utils.cpython-311.pyc new file mode 100644 index 0000000..b133c19 Binary files /dev/null and b/core/__pycache__/whatsapp_utils.cpython-311.pyc differ diff --git a/core/forms.py b/core/forms.py index 0f55053..552138d 100644 --- a/core/forms.py +++ b/core/forms.py @@ -69,7 +69,7 @@ class ParcelForm(forms.ModelForm): class Meta: model = Parcel fields = [ - 'description', 'weight', + 'description', 'weight', 'price', 'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address', 'delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address', 'receiver_name', 'receiver_phone' @@ -77,6 +77,7 @@ class ParcelForm(forms.ModelForm): widgets = { 'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}), 'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}), + 'price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), 'pickup_country': forms.Select(attrs={'class': 'form-control'}), 'pickup_governate': forms.Select(attrs={'class': 'form-control'}), @@ -94,6 +95,7 @@ class ParcelForm(forms.ModelForm): labels = { 'description': _('Package Description'), 'weight': _('Weight (kg)'), + 'price': _('Shipping Price (OMR)'), 'pickup_country': _('Pickup Country'), 'pickup_governate': _('Pickup Governate'), 'pickup_city': _('Pickup City'), @@ -142,4 +144,4 @@ class ParcelForm(forms.ModelForm): gov_id = int(self.data.get('delivery_governate')) self.fields['delivery_city'].queryset = City.objects.filter(governate_id=gov_id).order_by('name') except (ValueError, TypeError): - pass + pass \ No newline at end of file diff --git a/core/migrations/0004_parcel_payment_status_parcel_price_and_more.py b/core/migrations/0004_parcel_payment_status_parcel_price_and_more.py new file mode 100644 index 0000000..5e4e555 --- /dev/null +++ b/core/migrations/0004_parcel_payment_status_parcel_price_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.7 on 2026-01-25 07:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_city_country_parcel_delivery_city_parcel_pickup_city_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='parcel', + name='payment_status', + field=models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed')], default='pending', max_length=20, verbose_name='Payment Status'), + ), + migrations.AddField( + model_name='parcel', + name='price', + field=models.DecimalField(decimal_places=3, default=0.0, max_digits=10, verbose_name='Price (OMR)'), + ), + migrations.AddField( + model_name='parcel', + name='thawani_session_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Thawani Session ID'), + ), + migrations.AlterField( + model_name='parcel', + name='delivery_city', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='delivery_city_parcels', to='core.city', verbose_name='Delivery City'), + ), + ] diff --git a/core/migrations/__pycache__/0004_parcel_payment_status_parcel_price_and_more.cpython-311.pyc b/core/migrations/__pycache__/0004_parcel_payment_status_parcel_price_and_more.cpython-311.pyc new file mode 100644 index 0000000..b0bb0f6 Binary files /dev/null and b/core/migrations/__pycache__/0004_parcel_payment_status_parcel_price_and_more.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 5d7afec..6893819 100644 --- a/core/models.py +++ b/core/models.py @@ -76,12 +76,19 @@ class Parcel(models.Model): ('cancelled', _('Cancelled')), ) + PAYMENT_STATUS_CHOICES = ( + ('pending', _('Pending')), + ('paid', _('Paid')), + ('failed', _('Failed')), + ) + tracking_number = models.CharField(_('Tracking Number'), max_length=20, unique=True, blank=True) shipper = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_parcels', verbose_name=_('Shipper')) carrier = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='carried_parcels', verbose_name=_('Carrier')) description = models.TextField(_('Description')) weight = models.DecimalField(_('Weight (kg)'), max_digits=5, decimal_places=2, help_text=_("Weight in kg")) + price = models.DecimalField(_('Price (OMR)'), max_digits=10, decimal_places=3, default=0.000) # Pickup Location pickup_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Country')) @@ -92,13 +99,16 @@ class Parcel(models.Model): # Delivery Location delivery_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Country')) delivery_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Governate')) - delivery_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery City')) + delivery_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_city_parcels', verbose_name=_('Delivery City')) delivery_address = models.CharField(_('Delivery Address'), max_length=255) receiver_name = models.CharField(_('Receiver Name'), max_length=100) receiver_phone = models.CharField(_('Receiver Phone'), max_length=20) status = models.CharField(_('Status'), max_length=20, choices=STATUS_CHOICES, default='pending') + payment_status = models.CharField(_('Payment Status'), max_length=20, choices=PAYMENT_STATUS_CHOICES, default='pending') + thawani_session_id = models.CharField(_('Thawani Session ID'), max_length=255, blank=True, null=True) + created_at = models.DateTimeField(_('Created At'), auto_now_add=True) updated_at = models.DateTimeField(_('Updated At'), auto_now=True) @@ -112,4 +122,4 @@ class Parcel(models.Model): class Meta: verbose_name = _('Parcel') - verbose_name_plural = _('Parcels') \ No newline at end of file + verbose_name_plural = _('Parcels') diff --git a/core/payment_utils.py b/core/payment_utils.py new file mode 100644 index 0000000..31bd098 --- /dev/null +++ b/core/payment_utils.py @@ -0,0 +1,66 @@ +import requests +from django.conf import settings +import logging + +logger = logging.getLogger(__name__) + +class ThawaniPay: + def __init__(self): + self.api_key = settings.THAWANI_API_KEY + self.base_url = settings.THAWANI_API_URL + self.headers = { + "thawani-api-key": self.api_key, + "Content-Type": "application/json" + } + + def create_checkout_session(self, parcel, success_url, cancel_url): + endpoint = f"{self.base_url}/checkout/session" + + # Thawani expects price in baiza (1 OMR = 1000 baiza) + # We need to convert Decimal price to integer baiza + amount_baiza = int(parcel.price * 1000) + + payload = { + "client_reference_id": str(parcel.tracking_number), + "mode": "payment", + "products": [ + { + "name": f"Shipping for Parcel {parcel.tracking_number}", + "unit_amount": amount_baiza, + "quantity": 1 + } + ], + "success_url": success_url, + "cancel_url": cancel_url, + "metadata": { + "parcel_id": parcel.id, + "customer_name": parcel.shipper.get_full_name() or parcel.shipper.username, + "customer_phone": parcel.shipper.profile.phone_number + } + } + + try: + response = requests.post(endpoint, json=payload, headers=self.headers) + response.raise_for_status() + data = response.json() + if data.get("success"): + return data["data"]["session_id"] + else: + logger.error(f"Thawani Error: {data.get('description')}") + return None + except Exception as e: + logger.error(f"Thawani Request Failed: {str(e)}") + return None + + def get_checkout_session(self, session_id): + endpoint = f"{self.base_url}/checkout/session/{session_id}" + try: + response = requests.get(endpoint, headers=self.headers) + response.raise_for_status() + data = response.json() + if data.get("success"): + return data["data"] + return None + except Exception as e: + logger.error(f"Thawani Check Failed: {str(e)}") + return None diff --git a/core/templates/core/driver_dashboard.html b/core/templates/core/driver_dashboard.html index 7639897..f332690 100644 --- a/core/templates/core/driver_dashboard.html +++ b/core/templates/core/driver_dashboard.html @@ -26,7 +26,10 @@
{{ parcel.description|truncatechars:30 }}

{% trans "Pickup" %}: {{ parcel.pickup_address }}

{% trans "Delivery" %}: {{ parcel.delivery_address }}

-

{% trans "Weight" %}: {{ parcel.weight }} kg

+
+ {% trans "Weight" %}: {{ parcel.weight }} kg + {{ parcel.price }} OMR +
{% csrf_token %} @@ -79,4 +82,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/shipper_dashboard.html b/core/templates/core/shipper_dashboard.html index 9eb4ad4..32d54ce 100644 --- a/core/templates/core/shipper_dashboard.html +++ b/core/templates/core/shipper_dashboard.html @@ -23,6 +23,20 @@
{{ parcel.description|truncatechars:30 }}

{% trans "From" %}: {{ parcel.pickup_address }}

{% trans "To" %}: {{ parcel.delivery_address }}

+ +
+ {{ parcel.price }} OMR + + {{ parcel.get_payment_status_display }} + +
+ + {% if parcel.payment_status == 'pending' %} + + {% trans "Pay Now" %} + + {% endif %} +

{% trans "Receiver" %}: {{ parcel.receiver_name }}

{% trans "Carrier" %}: {% if parcel.carrier %}{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}{% else %}{% trans "Waiting for pickup" %}{% endif %}

@@ -38,4 +52,4 @@ {% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 3b347d2..50c776d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from django.contrib.auth import views as auth_views from . import views +from django.contrib.auth import views as auth_views urlpatterns = [ path('', views.index, name='index'), @@ -16,4 +16,9 @@ urlpatterns = [ # AJAX for locations path('ajax/get-governates/', views.get_governates, name='get_governates'), path('ajax/get-cities/', views.get_cities, name='get_cities'), -] + + # Thawani Payment + path('payment/initiate//', views.initiate_payment, name='initiate_payment'), + path('payment/success/', views.payment_success, name='payment_success'), + path('payment/cancel/', views.payment_cancel, name='payment_cancel'), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 09b0517..f174adf 100644 --- a/core/views.py +++ b/core/views.py @@ -7,6 +7,15 @@ from .forms import UserRegistrationForm, ParcelForm from django.utils.translation import gettext_lazy as _ from django.contrib import messages from django.http import JsonResponse +from django.urls import reverse +from .payment_utils import ThawaniPay +from django.conf import settings +from .whatsapp_utils import ( + notify_shipment_created, + notify_payment_received, + notify_driver_assigned, + notify_status_change +) def index(request): tracking_id = request.GET.get('tracking_id') @@ -45,7 +54,7 @@ def dashboard(request): return render(request, 'core/shipper_dashboard.html', {'parcels': parcels}) else: # Car Owner view - available_parcels = Parcel.objects.filter(status='pending').order_by('-created_at') + available_parcels = Parcel.objects.filter(status='pending', payment_status='paid').order_by('-created_at') my_parcels = Parcel.objects.filter(carrier=request.user).exclude(status='delivered').order_by('-created_at') return render(request, 'core/driver_dashboard.html', { 'available_parcels': available_parcels, @@ -65,6 +74,10 @@ def shipment_request(request): parcel = form.save(commit=False) parcel.shipper = request.user parcel.save() + + # WhatsApp Notification + notify_shipment_created(parcel) + messages.success(request, _("Shipment requested successfully! Tracking ID: ") + parcel.tracking_number) return redirect('dashboard') else: @@ -78,10 +91,14 @@ def accept_parcel(request, parcel_id): messages.error(request, _("Only car owners can accept shipments.")) return redirect('dashboard') - parcel = get_object_or_404(Parcel, id=parcel_id, status='pending') + parcel = get_object_or_404(Parcel, id=parcel_id, status='pending', payment_status='paid') parcel.carrier = request.user parcel.status = 'picked_up' parcel.save() + + # WhatsApp Notification + notify_driver_assigned(parcel) + messages.success(request, _("You have accepted the shipment!")) return redirect('dashboard') @@ -93,9 +110,59 @@ def update_status(request, parcel_id): if new_status in dict(Parcel.STATUS_CHOICES): parcel.status = new_status parcel.save() + + # WhatsApp Notification + notify_status_change(parcel) + messages.success(request, _("Status updated successfully!")) return redirect('dashboard') +@login_required +def initiate_payment(request, parcel_id): + parcel = get_object_or_404(Parcel, id=parcel_id, shipper=request.user, payment_status='pending') + + thawani = ThawaniPay() + success_url = request.build_absolute_uri(reverse('payment_success')) + f"?session_id={{CHECKOUT_SESSION_ID}}&parcel_id={parcel.id}" + cancel_url = request.build_absolute_uri(reverse('payment_cancel')) + f"?parcel_id={parcel.id}" + + session_id = thawani.create_checkout_session(parcel, success_url, cancel_url) + + if session_id: + parcel.thawani_session_id = session_id + parcel.save() + checkout_url = f"{settings.THAWANI_API_URL.replace('/api/v1', '')}/pay/{session_id}?key={settings.THAWANI_PUBLISHABLE_KEY}" + return redirect(checkout_url) + else: + messages.error(request, _("Could not initiate payment. Please try again later.")) + return redirect('dashboard') + +@login_required +def payment_success(request): + session_id = request.GET.get('session_id') + parcel_id = request.GET.get('parcel_id') + parcel = get_object_or_404(Parcel, id=parcel_id, shipper=request.user) + + thawani = ThawaniPay() + session_data = thawani.get_checkout_session(session_id) + + if session_data and session_data.get('payment_status') == 'paid': + parcel.payment_status = 'paid' + parcel.save() + + # WhatsApp Notification + notify_payment_received(parcel) + + messages.success(request, _("Payment successful! Your shipment is now active.")) + else: + messages.warning(request, _("Payment status is pending or failed. Please check your dashboard.")) + + return redirect('dashboard') + +@login_required +def payment_cancel(request): + messages.info(request, _("Payment was cancelled.")) + return redirect('dashboard') + def article_detail(request): return render(request, 'core/article_detail.html') @@ -107,4 +174,4 @@ def get_governates(request): def get_cities(request): governate_id = request.GET.get('governate_id') cities = City.objects.filter(governate_id=governate_id).values('id', 'name') - return JsonResponse(list(cities), safe=False) + return JsonResponse(list(cities), safe=False) \ No newline at end of file diff --git a/core/whatsapp_utils.py b/core/whatsapp_utils.py new file mode 100644 index 0000000..d89b48e --- /dev/null +++ b/core/whatsapp_utils.py @@ -0,0 +1,92 @@ +import requests +import logging +from django.conf import settings + +logger = logging.getLogger(__name__) + +def send_whatsapp_message(phone_number, message): + """ + Sends a WhatsApp message using the configured gateway. + This implementation assumes Meta WhatsApp Business API (Graph API). + """ + if not settings.WHATSAPP_ENABLED: + logger.info("WhatsApp notifications are disabled.") + return False + + if not settings.WHATSAPP_API_KEY or not settings.WHATSAPP_PHONE_ID: + logger.warning("WhatsApp API configuration is missing.") + return False + + # Normalize phone number (ensure it has country code and no +) + clean_phone = "".join(filter(str.isdigit, str(phone_number))) + + url = f"https://graph.facebook.com/v17.0/{settings.WHATSAPP_PHONE_ID}/messages" + + headers = { + "Authorization": f"Bearer {settings.WHATSAPP_API_KEY}", + "Content-Type": "application/json", + } + + payload = { + "messaging_product": "whatsapp", + "to": clean_phone, + "type": "text", + "text": {"body": message} + } + + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + response_data = response.json() + + if response.status_code == 200: + logger.info(f"WhatsApp message sent to {clean_phone}") + return True + else: + logger.error(f"WhatsApp API error: {response.status_code} - {response_data}") + return False + except Exception as e: + logger.error(f"Failed to send WhatsApp message: {str(e)}") + return False + +def notify_shipment_created(parcel): + """Notifies the shipper that the shipment request was received.""" + shipper_name = parcel.shipper.get_full_name() or parcel.shipper.username + message = f"""Hello {shipper_name}, + +Your shipment request for '{parcel.description}' has been received. +Tracking Number: {parcel.tracking_number} +Status: {parcel.get_status_display()} + +Please proceed to payment to make it visible to drivers.""" + return send_whatsapp_message(parcel.shipper.profile.phone_number, message) + +def notify_payment_received(parcel): + """Notifies the shipper and receiver about successful payment.""" + # Notify Shipper + shipper_name = parcel.shipper.get_full_name() or parcel.shipper.username + shipper_msg = f"""Payment successful for shipment {parcel.tracking_number}. +Your shipment is now visible to available drivers.""" + send_whatsapp_message(parcel.shipper.profile.phone_number, shipper_msg) + + # Notify Receiver + receiver_msg = f"""Hello {parcel.receiver_name}, + +A shipment is coming your way from {shipper_name}. +Tracking Number: {parcel.tracking_number} +Status: {parcel.get_status_display()}""" + send_whatsapp_message(parcel.receiver_phone, receiver_msg) + +def notify_driver_assigned(parcel): + """Notifies the shipper and receiver that a driver has picked up the parcel.""" + driver_name = parcel.carrier.get_full_name() or parcel.carrier.username + msg = f"""Shipment {parcel.tracking_number} has been picked up by {driver_name}. +Status: {parcel.get_status_display()}""" + send_whatsapp_message(parcel.shipper.profile.phone_number, msg) + send_whatsapp_message(parcel.receiver_phone, msg) + +def notify_status_change(parcel): + """Notifies parties about general status updates (In Transit, Delivered).""" + msg = f"""Update for shipment {parcel.tracking_number}: +New Status: {parcel.get_status_display()}""" + send_whatsapp_message(parcel.shipper.profile.phone_number, msg) + send_whatsapp_message(parcel.receiver_phone, msg)