1018 lines
38 KiB
Python
1018 lines
38 KiB
Python
import logging
|
|
from decimal import Decimal
|
|
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
from accounts.delivery import (
|
|
build_delivery_payload,
|
|
delivery_payload_from_profile,
|
|
derive_location_label,
|
|
parse_decimal_value,
|
|
phone_has_valid_digits,
|
|
save_profile_delivery_defaults,
|
|
)
|
|
from cart.views import get_cart, save_cart
|
|
from products.models import Product
|
|
|
|
from .models import Order, OrderItem
|
|
from .payments import (
|
|
PaymentGatewayError,
|
|
build_esewa_redirect,
|
|
construct_webhook_event,
|
|
create_checkout_session,
|
|
esewa_configured,
|
|
initiate_khalti_payment,
|
|
khalti_configured,
|
|
payment_currency,
|
|
retrieve_checkout_session,
|
|
stripe_configured,
|
|
stripe_value,
|
|
verify_esewa_payment,
|
|
verify_khalti_payment,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STATUS_SEQUENCE = ['Pending', 'Paid', 'Shipped', 'Delivered']
|
|
UNSET = object()
|
|
|
|
|
|
def _build_timeline(status):
|
|
current_index = STATUS_SEQUENCE.index(status) if status in STATUS_SEQUENCE else 0
|
|
timeline = []
|
|
for idx, step in enumerate(STATUS_SEQUENCE):
|
|
state = 'done' if idx < current_index else 'current' if idx == current_index else 'upcoming'
|
|
timeline.append({'label': step, 'state': state})
|
|
return timeline
|
|
|
|
|
|
def _get_checkout_source(request):
|
|
buy_now = request.session.get('buy_now', {})
|
|
if isinstance(buy_now, dict) and buy_now:
|
|
return buy_now, 'buy_now'
|
|
return get_cart(request), 'cart'
|
|
|
|
|
|
|
|
def _clear_checkout_source(request, source_key):
|
|
if source_key == 'buy_now':
|
|
request.session.pop('buy_now', None)
|
|
request.session.modified = True
|
|
else:
|
|
save_cart(request, {})
|
|
|
|
|
|
|
|
def _build_delivery_shortcuts(user, profile, *, limit=4):
|
|
shortcuts = []
|
|
seen = set()
|
|
default_full_name = user.get_full_name() or user.username
|
|
|
|
def add_shortcut(
|
|
identifier,
|
|
*,
|
|
badge,
|
|
title,
|
|
note='',
|
|
full_name='',
|
|
phone='',
|
|
address='',
|
|
location_label='',
|
|
delivery_notes='',
|
|
latitude=None,
|
|
longitude=None,
|
|
location_accuracy_m=None,
|
|
):
|
|
payload = build_delivery_payload(
|
|
full_name=full_name,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
delivery_notes=delivery_notes,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
location_accuracy_m=location_accuracy_m,
|
|
save_as_default=True,
|
|
)
|
|
dedupe_key = (
|
|
payload['phone'].lower(),
|
|
payload['address'].lower(),
|
|
payload['location_label'].lower(),
|
|
)
|
|
if not payload['address'] or dedupe_key in seen:
|
|
return
|
|
|
|
seen.add(dedupe_key)
|
|
shortcuts.append(
|
|
{
|
|
'id': identifier,
|
|
'badge': badge,
|
|
'title': title,
|
|
'note': note,
|
|
'full_name': payload['full_name'],
|
|
'phone': payload['phone'],
|
|
'address': payload['address'],
|
|
'location_label': payload['location_label'],
|
|
'delivery_notes': payload['delivery_notes'],
|
|
'latitude': payload['latitude'],
|
|
'longitude': payload['longitude'],
|
|
'location_accuracy_m': payload['location_accuracy_m'],
|
|
'has_precise_location': bool(payload['latitude'] and payload['longitude']),
|
|
'selected': False,
|
|
}
|
|
)
|
|
|
|
if profile is not None and (profile.phone or profile.formatted_delivery_address):
|
|
add_shortcut(
|
|
'profile-default',
|
|
badge='Saved profile',
|
|
title=profile.short_location or 'Default account address',
|
|
note='Use your currently saved checkout defaults.',
|
|
full_name=default_full_name,
|
|
phone=profile.phone,
|
|
address=profile.formatted_delivery_address,
|
|
location_label=profile.short_location,
|
|
latitude=profile.latitude,
|
|
longitude=profile.longitude,
|
|
location_accuracy_m=profile.location_accuracy_m,
|
|
)
|
|
|
|
recent_orders = (
|
|
Order.objects.filter(user=user)
|
|
.exclude(address='')
|
|
.order_by('-created_at')
|
|
.only(
|
|
'id',
|
|
'full_name',
|
|
'phone',
|
|
'address',
|
|
'location_label',
|
|
'delivery_notes',
|
|
'latitude',
|
|
'longitude',
|
|
'location_accuracy_m',
|
|
'created_at',
|
|
)
|
|
)
|
|
|
|
for order in recent_orders:
|
|
add_shortcut(
|
|
f'order-{order.id}',
|
|
badge=f'Order #{order.id}',
|
|
title=order.location_label or order.address.splitlines()[0].strip() or f'Order #{order.id}',
|
|
note=f'Last used {order.created_at:%b %d, %Y}.',
|
|
full_name=order.full_name or default_full_name,
|
|
phone=order.phone,
|
|
address=order.address,
|
|
location_label=order.location_label,
|
|
delivery_notes=order.delivery_notes,
|
|
latitude=order.latitude,
|
|
longitude=order.longitude,
|
|
location_accuracy_m=order.location_accuracy_m,
|
|
)
|
|
if len(shortcuts) >= limit:
|
|
break
|
|
|
|
return shortcuts
|
|
|
|
|
|
|
|
def _resolve_delivery_shortcut(shortcuts, shortcut_id):
|
|
if not shortcut_id:
|
|
return None
|
|
return next((shortcut for shortcut in shortcuts if shortcut['id'] == shortcut_id), None)
|
|
|
|
|
|
|
|
def _set_selected_delivery_shortcut(shortcuts, shortcut_id):
|
|
for shortcut in shortcuts:
|
|
shortcut['selected'] = shortcut['id'] == shortcut_id
|
|
|
|
|
|
|
|
def _render_checkout(request, *, cart_products, total, delivery, delivery_shortcuts):
|
|
return render(
|
|
request,
|
|
'order/checkout.html',
|
|
{
|
|
'products': cart_products,
|
|
'total': total,
|
|
'delivery': delivery,
|
|
'delivery_shortcuts': delivery_shortcuts,
|
|
},
|
|
)
|
|
|
|
|
|
|
|
def _save_order_updates(order, **changes):
|
|
update_fields = []
|
|
for field, value in changes.items():
|
|
if value is UNSET:
|
|
continue
|
|
if getattr(order, field) != value:
|
|
setattr(order, field, value)
|
|
update_fields.append(field)
|
|
if update_fields:
|
|
order.save(update_fields=update_fields)
|
|
|
|
|
|
|
|
def _mark_order_paid(
|
|
order,
|
|
*,
|
|
payment_method=UNSET,
|
|
payment_provider=UNSET,
|
|
payment_reference=UNSET,
|
|
payment_session_id=UNSET,
|
|
payment_currency_value=UNSET,
|
|
):
|
|
_save_order_updates(
|
|
order,
|
|
status='Paid',
|
|
payment_status='Paid',
|
|
payment_method=payment_method,
|
|
payment_provider=payment_provider,
|
|
payment_reference=payment_reference,
|
|
payment_session_id=payment_session_id,
|
|
payment_currency=payment_currency_value,
|
|
paid_at=order.paid_at or timezone.now(),
|
|
)
|
|
|
|
|
|
|
|
def _mark_order_paid_from_stripe(order, session):
|
|
payment_reference = stripe_value(session, 'payment_intent', '') or UNSET
|
|
payment_session_id = stripe_value(session, 'id', '') or UNSET
|
|
payment_currency_value = (stripe_value(session, 'currency', '') or settings.STRIPE_CURRENCY).upper()
|
|
_mark_order_paid(
|
|
order,
|
|
payment_method='Card / Wallet',
|
|
payment_provider='stripe',
|
|
payment_reference=payment_reference,
|
|
payment_session_id=payment_session_id,
|
|
payment_currency_value=payment_currency_value,
|
|
)
|
|
|
|
|
|
|
|
def _build_payment_options(order):
|
|
stripe_ready = stripe_configured()
|
|
esewa_ready = esewa_configured()
|
|
khalti_ready = khalti_configured()
|
|
|
|
options = [
|
|
{
|
|
'value': 'esewa',
|
|
'title': 'eSewa ePay',
|
|
'badge': 'Sandbox / UAT' if settings.ESEWA_SANDBOX else 'Live gateway',
|
|
'badge_muted': not esewa_ready,
|
|
'description': 'Redirect to the official eSewa wallet page and verify the transaction on return before marking the order paid.',
|
|
'note': (
|
|
'Ready to test with the official eSewa sandbox merchant.'
|
|
if esewa_ready and settings.ESEWA_SANDBOX
|
|
else 'Add your live eSewa merchant code and secret to switch this gateway to production.'
|
|
if esewa_ready
|
|
else 'Configure eSewa credentials to enable this wallet.'
|
|
),
|
|
'ready': esewa_ready,
|
|
},
|
|
{
|
|
'value': 'khalti',
|
|
'title': 'Khalti',
|
|
'badge': 'KPG-2',
|
|
'badge_muted': not khalti_ready,
|
|
'description': 'Create a real Khalti payment session on the server and verify its final status using the lookup API.',
|
|
'note': (
|
|
'Ready with your configured Khalti secret key.'
|
|
if khalti_ready
|
|
else 'Add KHALTI_SECRET_KEY to enable Khalti ePayment.'
|
|
),
|
|
'ready': khalti_ready,
|
|
},
|
|
{
|
|
'value': 'fonepay',
|
|
'title': 'Fonepay',
|
|
'badge': 'Merchant onboarding',
|
|
'badge_muted': True,
|
|
'description': 'Reserved for Fonepay Business / Dynamic QR integration once merchant onboarding details are available.',
|
|
'note': 'Kept disabled so the app never claims a Fonepay payment succeeded without official verification.',
|
|
'ready': False,
|
|
},
|
|
]
|
|
|
|
if stripe_ready:
|
|
options.append(
|
|
{
|
|
'value': 'stripe',
|
|
'title': 'Card / Wallet',
|
|
'badge': 'Stripe Checkout',
|
|
'badge_muted': False,
|
|
'description': 'Fallback card checkout with Stripe, verified before the order is marked paid.',
|
|
'note': f'Charges are created in {settings.STRIPE_CURRENCY.upper()} and verified on return/webhook.',
|
|
'ready': True,
|
|
}
|
|
)
|
|
|
|
options.append(
|
|
{
|
|
'value': 'cod',
|
|
'title': 'Cash on Delivery',
|
|
'badge': 'Offline',
|
|
'badge_muted': True,
|
|
'description': 'Confirm the order now and collect payment when the package arrives.',
|
|
'note': 'You can still return later and switch to eSewa or Khalti before delivery.',
|
|
'ready': True,
|
|
}
|
|
)
|
|
|
|
ready_option_values = {option['value'] for option in options if option['ready']}
|
|
selected_payment = None
|
|
|
|
if order.payment_provider in ready_option_values:
|
|
selected_payment = order.payment_provider
|
|
elif order.payment_method == 'Cash on Delivery':
|
|
selected_payment = 'cod'
|
|
else:
|
|
selected_payment = next((option['value'] for option in options if option['ready'] and option['value'] != 'cod'), 'cod')
|
|
|
|
for option in options:
|
|
option['checked'] = option['value'] == selected_payment
|
|
|
|
online_ready = any(option['ready'] and option['value'] != 'cod' for option in options)
|
|
|
|
return {
|
|
'payment_options': options,
|
|
'online_ready': online_ready,
|
|
'stripe_ready': stripe_ready,
|
|
'esewa_ready': esewa_ready,
|
|
'khalti_ready': khalti_ready,
|
|
}
|
|
|
|
|
|
@login_required
|
|
def checkout(request):
|
|
source_items, source_key = _get_checkout_source(request)
|
|
if not source_items:
|
|
messages.warning(request, 'Your cart is empty. Add products before checking out.')
|
|
return redirect('cart')
|
|
|
|
cart_products = []
|
|
total = Decimal('0')
|
|
|
|
for id, qty in source_items.items():
|
|
try:
|
|
product_id = int(id)
|
|
quantity = int(qty)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
if quantity <= 0:
|
|
continue
|
|
|
|
product = Product.objects.filter(id=product_id).first()
|
|
if not product:
|
|
messages.error(request, 'One of the selected products is no longer available.')
|
|
_clear_checkout_source(request, source_key)
|
|
return redirect('cart')
|
|
|
|
if quantity > product.stock:
|
|
if source_key == 'buy_now':
|
|
messages.error(request, f'Only {product.stock} units of {product.name} are available.')
|
|
_clear_checkout_source(request, source_key)
|
|
return redirect('product_detail', product_id=product.id)
|
|
|
|
messages.error(request, f'Only {product.stock} units of {product.name} are available.')
|
|
return redirect('cart')
|
|
|
|
product.qty = quantity
|
|
product.subtotal = product.display_price * quantity
|
|
total += product.subtotal
|
|
cart_products.append(product)
|
|
|
|
if not cart_products:
|
|
messages.warning(request, 'No valid products found for checkout.')
|
|
_clear_checkout_source(request, source_key)
|
|
return redirect('cart')
|
|
|
|
profile = getattr(request.user, 'profile', None)
|
|
delivery_shortcuts = _build_delivery_shortcuts(request.user, profile)
|
|
selected_shortcut_id = (
|
|
request.POST.get('selected_shortcut_id', '').strip()
|
|
if request.method == 'POST'
|
|
else request.GET.get('delivery_shortcut', '').strip()
|
|
)
|
|
selected_shortcut = _resolve_delivery_shortcut(delivery_shortcuts, selected_shortcut_id)
|
|
if selected_shortcut is None:
|
|
selected_shortcut_id = ''
|
|
_set_selected_delivery_shortcut(delivery_shortcuts, selected_shortcut_id)
|
|
|
|
if request.method == 'POST':
|
|
full_name = request.POST.get('full_name', '').strip()
|
|
phone = request.POST.get('phone', '').strip()
|
|
address = request.POST.get('address', '').strip()
|
|
location_label = request.POST.get('location_label', '').strip()
|
|
delivery_notes = request.POST.get('delivery_notes', '').strip()
|
|
save_as_default = request.POST.get('save_as_default') == 'on'
|
|
|
|
try:
|
|
latitude = parse_decimal_value(
|
|
request.POST.get('latitude'),
|
|
field_label='latitude',
|
|
minimum=Decimal('-90'),
|
|
maximum=Decimal('90'),
|
|
)
|
|
longitude = parse_decimal_value(
|
|
request.POST.get('longitude'),
|
|
field_label='longitude',
|
|
minimum=Decimal('-180'),
|
|
maximum=Decimal('180'),
|
|
)
|
|
location_accuracy_m = parse_decimal_value(
|
|
request.POST.get('location_accuracy_m'),
|
|
field_label='location accuracy',
|
|
minimum=Decimal('0'),
|
|
maximum=Decimal('100000'),
|
|
)
|
|
except ValueError as exc:
|
|
messages.error(request, str(exc))
|
|
return _render_checkout(
|
|
request,
|
|
cart_products=cart_products,
|
|
total=total,
|
|
delivery=build_delivery_payload(
|
|
full_name=full_name,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
delivery_notes=delivery_notes,
|
|
latitude=request.POST.get('latitude', ''),
|
|
longitude=request.POST.get('longitude', ''),
|
|
location_accuracy_m=request.POST.get('location_accuracy_m', ''),
|
|
save_as_default=save_as_default,
|
|
selected_shortcut_id=selected_shortcut_id,
|
|
),
|
|
delivery_shortcuts=delivery_shortcuts,
|
|
)
|
|
|
|
if (latitude is None) != (longitude is None):
|
|
messages.error(request, 'GPS location is incomplete. Please retry the location button or continue without GPS.')
|
|
return _render_checkout(
|
|
request,
|
|
cart_products=cart_products,
|
|
total=total,
|
|
delivery=build_delivery_payload(
|
|
full_name=full_name,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
delivery_notes=delivery_notes,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
location_accuracy_m=location_accuracy_m,
|
|
save_as_default=save_as_default,
|
|
selected_shortcut_id=selected_shortcut_id,
|
|
),
|
|
delivery_shortcuts=delivery_shortcuts,
|
|
)
|
|
|
|
location_label = derive_location_label(location_label, address)
|
|
|
|
if not full_name or not phone or not address:
|
|
messages.error(request, 'Please fill in full name, phone, and address.')
|
|
return _render_checkout(
|
|
request,
|
|
cart_products=cart_products,
|
|
total=total,
|
|
delivery=build_delivery_payload(
|
|
full_name=full_name,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
delivery_notes=delivery_notes,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
location_accuracy_m=location_accuracy_m,
|
|
save_as_default=save_as_default,
|
|
selected_shortcut_id=selected_shortcut_id,
|
|
),
|
|
delivery_shortcuts=delivery_shortcuts,
|
|
)
|
|
|
|
if not phone_has_valid_digits(phone):
|
|
messages.error(request, 'Please enter a valid phone number.')
|
|
return _render_checkout(
|
|
request,
|
|
cart_products=cart_products,
|
|
total=total,
|
|
delivery=build_delivery_payload(
|
|
full_name=full_name,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
delivery_notes=delivery_notes,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
location_accuracy_m=location_accuracy_m,
|
|
save_as_default=save_as_default,
|
|
selected_shortcut_id=selected_shortcut_id,
|
|
),
|
|
delivery_shortcuts=delivery_shortcuts,
|
|
)
|
|
|
|
order = Order.objects.create(
|
|
user=request.user,
|
|
total_price=total,
|
|
status='Pending',
|
|
payment_status='Unpaid',
|
|
payment_method='Pending selection',
|
|
payment_currency=payment_currency(),
|
|
full_name=full_name,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
location_accuracy_m=location_accuracy_m,
|
|
delivery_notes=delivery_notes,
|
|
)
|
|
|
|
for product in cart_products:
|
|
OrderItem.objects.create(order=order, product=product, quantity=product.qty, price=product.display_price)
|
|
product.stock = max(product.stock - product.qty, 0)
|
|
product.save(update_fields=['stock'])
|
|
|
|
if save_as_default:
|
|
save_profile_delivery_defaults(
|
|
profile,
|
|
phone=phone,
|
|
address=address,
|
|
location_label=location_label,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
location_accuracy_m=location_accuracy_m,
|
|
)
|
|
|
|
if location_label:
|
|
request.session['delivery_location'] = location_label
|
|
else:
|
|
request.session.pop('delivery_location', None)
|
|
|
|
_clear_checkout_source(request, source_key)
|
|
messages.success(request, 'Order created successfully. Complete the payment step to finish checkout.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
initial_delivery = delivery_payload_from_profile(
|
|
profile,
|
|
full_name=request.user.get_full_name() or request.user.username,
|
|
selected_shortcut_id=selected_shortcut_id,
|
|
)
|
|
if selected_shortcut is not None:
|
|
initial_delivery = build_delivery_payload(
|
|
full_name=selected_shortcut['full_name'] or request.user.get_full_name() or request.user.username,
|
|
phone=selected_shortcut['phone'],
|
|
address=selected_shortcut['address'],
|
|
location_label=selected_shortcut['location_label'],
|
|
delivery_notes=selected_shortcut['delivery_notes'],
|
|
latitude=selected_shortcut['latitude'],
|
|
longitude=selected_shortcut['longitude'],
|
|
location_accuracy_m=selected_shortcut['location_accuracy_m'],
|
|
save_as_default=True,
|
|
selected_shortcut_id=selected_shortcut_id,
|
|
)
|
|
|
|
return _render_checkout(
|
|
request,
|
|
cart_products=cart_products,
|
|
total=total,
|
|
delivery=initial_delivery,
|
|
delivery_shortcuts=delivery_shortcuts,
|
|
)
|
|
|
|
|
|
@login_required
|
|
def payment_page(request, order_id):
|
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
|
payment_context = _build_payment_options(order)
|
|
|
|
if order.payment_status == 'Paid' or order.status == 'Paid':
|
|
messages.info(request, 'This order has already been paid.')
|
|
return redirect('order_detail', order_id=order.id)
|
|
|
|
if request.GET.get('cancelled'):
|
|
messages.warning(request, 'Payment was cancelled. Your order is still saved and waiting for payment.')
|
|
|
|
if request.method == 'POST':
|
|
method = request.POST.get('payment', '').strip()
|
|
|
|
if method == 'esewa':
|
|
if not payment_context['esewa_ready']:
|
|
messages.error(request, 'eSewa is not configured yet. Add the merchant credentials to enable it.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
try:
|
|
gateway = build_esewa_redirect(request, order)
|
|
except PaymentGatewayError as exc:
|
|
messages.error(request, str(exc))
|
|
return redirect('payment', order_id=order.id)
|
|
except Exception:
|
|
logger.exception('Unable to start eSewa payment for order %s', order.id)
|
|
messages.error(request, 'We could not start the eSewa payment window right now. Please try again in a moment.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='eSewa',
|
|
payment_provider='esewa',
|
|
payment_status='Pending',
|
|
payment_reference='',
|
|
payment_session_id=gateway['session_id'],
|
|
payment_currency=gateway['currency'],
|
|
)
|
|
return render(
|
|
request,
|
|
'order/payment_redirect.html',
|
|
{
|
|
'order': order,
|
|
'gateway_name': 'eSewa',
|
|
'action_url': gateway['action_url'],
|
|
'fields': gateway['fields'],
|
|
},
|
|
)
|
|
|
|
if method == 'khalti':
|
|
if not payment_context['khalti_ready']:
|
|
messages.error(request, 'Khalti is not configured yet. Add the Khalti secret key to enable it.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
try:
|
|
gateway = initiate_khalti_payment(request, order)
|
|
except PaymentGatewayError as exc:
|
|
messages.error(request, str(exc))
|
|
return redirect('payment', order_id=order.id)
|
|
except Exception:
|
|
logger.exception('Unable to start Khalti payment for order %s', order.id)
|
|
messages.error(request, 'We could not start Khalti right now. Please try again in a moment.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='Khalti',
|
|
payment_provider='khalti',
|
|
payment_status='Pending',
|
|
payment_reference=gateway['reference'],
|
|
payment_session_id=gateway['session_id'],
|
|
payment_currency=gateway['currency'],
|
|
)
|
|
return redirect(gateway['payment_url'])
|
|
|
|
if method == 'fonepay':
|
|
messages.error(
|
|
request,
|
|
'Fonepay automation is intentionally disabled until the official merchant onboarding/API details are available.',
|
|
)
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
if method == 'stripe':
|
|
if not payment_context['stripe_ready']:
|
|
messages.error(request, 'Online card payments are not configured yet. Add a Stripe secret key to enable them.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
try:
|
|
session = create_checkout_session(request, order)
|
|
except Exception:
|
|
logger.exception('Unable to start Stripe Checkout for order %s', order.id)
|
|
messages.error(request, 'We could not start the secure payment window right now. Please try again in a moment.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='Card / Wallet',
|
|
payment_provider='stripe',
|
|
payment_status='Pending',
|
|
payment_currency=settings.STRIPE_CURRENCY.upper(),
|
|
payment_session_id=stripe_value(session, 'id', '') or '',
|
|
)
|
|
|
|
checkout_url = stripe_value(session, 'url')
|
|
if not checkout_url:
|
|
messages.error(request, 'Stripe did not return a checkout URL. Please try again.')
|
|
return redirect('payment', order_id=order.id)
|
|
return redirect(checkout_url)
|
|
|
|
if method == 'cod':
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='Cash on Delivery',
|
|
payment_provider='offline',
|
|
payment_status='Pending',
|
|
payment_reference='',
|
|
payment_session_id='',
|
|
payment_currency=payment_currency(),
|
|
)
|
|
messages.success(request, 'Cash on delivery selected. You can still switch to an online wallet later if you want.')
|
|
return redirect(f"{reverse('success')}?order_id={order.id}")
|
|
|
|
messages.error(request, 'Please choose a supported payment method.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
return render(
|
|
request,
|
|
'order/payment.html',
|
|
{
|
|
'order': order,
|
|
'currency_code': payment_currency(),
|
|
'esewa_sandbox': settings.ESEWA_SANDBOX,
|
|
**payment_context,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def payment_gateway(request, order_id, gateway):
|
|
messages.info(request, 'Use the payment selection page to start the gateway securely.')
|
|
return redirect('payment', order_id=order_id)
|
|
|
|
|
|
@login_required
|
|
def esewa_return(request):
|
|
order_id = request.GET.get('order_id')
|
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
|
|
|
if order.payment_status == 'Paid':
|
|
messages.info(request, 'This order has already been paid.')
|
|
return redirect(f"{reverse('success')}?order_id={order.id}")
|
|
|
|
if request.GET.get('failed'):
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='eSewa',
|
|
payment_provider='esewa',
|
|
payment_status='Failed',
|
|
payment_currency=payment_currency(),
|
|
)
|
|
messages.warning(request, 'eSewa payment was not completed. Your order is still saved and waiting for payment.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
try:
|
|
result = verify_esewa_payment(order, request.GET.get('data', ''))
|
|
except PaymentGatewayError as exc:
|
|
logger.warning('Unable to verify eSewa payment for order %s: %s', order.id, exc)
|
|
messages.error(request, str(exc))
|
|
return redirect('payment', order_id=order.id)
|
|
except Exception:
|
|
logger.exception('Unexpected eSewa verification error for order %s', order.id)
|
|
messages.error(request, 'We could not verify the eSewa payment yet. Please try again.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
if result['status'] == 'Paid':
|
|
_mark_order_paid(
|
|
order,
|
|
payment_method='eSewa',
|
|
payment_provider='esewa',
|
|
payment_reference=result['reference'],
|
|
payment_session_id=result['session_id'],
|
|
payment_currency_value=result['currency'],
|
|
)
|
|
messages.success(request, result['message'])
|
|
return redirect(f"{reverse('success')}?order_id={order.id}")
|
|
|
|
if result['status'] == 'Pending':
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='eSewa',
|
|
payment_provider='esewa',
|
|
payment_status='Pending',
|
|
payment_session_id=result['session_id'],
|
|
payment_currency=result['currency'],
|
|
)
|
|
messages.info(request, result['message'])
|
|
else:
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='eSewa',
|
|
payment_provider='esewa',
|
|
payment_status='Failed',
|
|
payment_session_id=result['session_id'],
|
|
payment_currency=result['currency'],
|
|
)
|
|
messages.warning(request, result['message'])
|
|
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
|
|
@login_required
|
|
def khalti_return(request):
|
|
order_id = request.GET.get('order_id')
|
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
|
|
|
if order.payment_status == 'Paid':
|
|
messages.info(request, 'This order has already been paid.')
|
|
return redirect(f"{reverse('success')}?order_id={order.id}")
|
|
|
|
pidx = request.GET.get('pidx', '').strip()
|
|
if not pidx:
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='Khalti',
|
|
payment_provider='khalti',
|
|
payment_status='Failed',
|
|
payment_currency=payment_currency(),
|
|
)
|
|
messages.warning(request, 'Khalti payment was not completed. Your order is still saved and waiting for payment.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
try:
|
|
result = verify_khalti_payment(order, pidx)
|
|
except PaymentGatewayError as exc:
|
|
logger.warning('Unable to verify Khalti payment for order %s: %s', order.id, exc)
|
|
messages.error(request, str(exc))
|
|
return redirect('payment', order_id=order.id)
|
|
except Exception:
|
|
logger.exception('Unexpected Khalti verification error for order %s', order.id)
|
|
messages.error(request, 'We could not verify the Khalti payment yet. Please try again.')
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
if result['status'] == 'Paid':
|
|
_mark_order_paid(
|
|
order,
|
|
payment_method='Khalti',
|
|
payment_provider='khalti',
|
|
payment_reference=result['reference'],
|
|
payment_session_id=result['session_id'],
|
|
payment_currency_value=result['currency'],
|
|
)
|
|
messages.success(request, result['message'])
|
|
return redirect(f"{reverse('success')}?order_id={order.id}")
|
|
|
|
if result['status'] == 'Pending':
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='Khalti',
|
|
payment_provider='khalti',
|
|
payment_status='Pending',
|
|
payment_session_id=result['session_id'],
|
|
payment_currency=result['currency'],
|
|
)
|
|
messages.info(request, result['message'])
|
|
else:
|
|
_save_order_updates(
|
|
order,
|
|
payment_method='Khalti',
|
|
payment_provider='khalti',
|
|
payment_status='Failed',
|
|
payment_session_id=result['session_id'],
|
|
payment_currency=result['currency'],
|
|
)
|
|
messages.warning(request, result['message'])
|
|
|
|
return redirect('payment', order_id=order.id)
|
|
|
|
|
|
@login_required
|
|
def success(request):
|
|
order_id = request.GET.get('order_id')
|
|
session_id = request.GET.get('session_id', '').strip()
|
|
|
|
if not order_id:
|
|
messages.info(request, 'Open an order to view its payment status.')
|
|
return redirect('my_orders')
|
|
|
|
order = get_object_or_404(Order, id=order_id, user=request.user)
|
|
payment_verified = order.payment_status == 'Paid'
|
|
verification_message = 'Your order has been created successfully.'
|
|
|
|
if session_id:
|
|
if not stripe_configured():
|
|
verification_message = 'The order exists, but Stripe is not configured on the server to verify the payment session.'
|
|
else:
|
|
try:
|
|
session = retrieve_checkout_session(session_id)
|
|
except Exception:
|
|
logger.exception('Unable to verify Stripe checkout session %s for order %s', session_id, order.id)
|
|
verification_message = 'We could not verify the payment yet. Please refresh this page or open the order details page.'
|
|
else:
|
|
metadata = stripe_value(session, 'metadata', {}) or {}
|
|
session_order_id = str(metadata.get('order_id') or stripe_value(session, 'client_reference_id', '') or '')
|
|
if session_order_id != str(order.id):
|
|
verification_message = 'The returned payment session did not match this order, so payment could not be confirmed.'
|
|
elif stripe_value(session, 'payment_status') == 'paid':
|
|
_mark_order_paid_from_stripe(order, session)
|
|
payment_verified = True
|
|
verification_message = 'Payment verified successfully. Your order is confirmed.'
|
|
else:
|
|
verification_message = 'Your order was created, but Stripe has not marked the payment as paid yet.'
|
|
elif order.payment_status == 'Paid':
|
|
payment_verified = True
|
|
verification_message = 'Payment verified successfully. Your order is confirmed.'
|
|
elif order.payment_status == 'Failed':
|
|
provider_name = order.payment_provider_label if order.payment_provider else 'payment gateway'
|
|
verification_message = f'The last {provider_name} attempt did not complete. You can retry from the payment page.'
|
|
elif order.payment_method == 'Cash on Delivery':
|
|
verification_message = 'Your order is confirmed. Payment will be collected on delivery, and you can still switch to an online wallet before shipment if needed.'
|
|
elif order.payment_provider == 'esewa':
|
|
verification_message = 'Your order was created, but eSewa has not marked the payment as complete yet.'
|
|
elif order.payment_provider == 'khalti':
|
|
verification_message = 'Your order was created, but Khalti has not marked the payment as complete yet.'
|
|
elif order.payment_provider == 'stripe':
|
|
verification_message = 'Your order was created, but Stripe has not marked the payment as complete yet.'
|
|
else:
|
|
verification_message = 'Your order is waiting for payment. You can return to the payment page any time.'
|
|
|
|
return render(
|
|
request,
|
|
'order/success.html',
|
|
{
|
|
'order': order,
|
|
'payment_verified': payment_verified,
|
|
'verification_message': verification_message,
|
|
},
|
|
)
|
|
|
|
|
|
@csrf_exempt
|
|
def stripe_webhook(request):
|
|
if request.method != 'POST':
|
|
return HttpResponse(status=405)
|
|
|
|
if not stripe_configured() or not settings.STRIPE_WEBHOOK_SECRET:
|
|
return HttpResponse('Stripe webhook is not configured.', status=503)
|
|
|
|
signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
|
|
|
|
try:
|
|
event = construct_webhook_event(request.body, signature)
|
|
except Exception:
|
|
logger.exception('Invalid Stripe webhook received')
|
|
return HttpResponse(status=400)
|
|
|
|
event_type = event.get('type', '')
|
|
session = event.get('data', {}).get('object', {})
|
|
metadata = stripe_value(session, 'metadata', {}) or {}
|
|
order_id = str(metadata.get('order_id') or stripe_value(session, 'client_reference_id', '') or '').strip()
|
|
order = Order.objects.filter(id=order_id).first() if order_id else None
|
|
|
|
if order and event_type in {'checkout.session.completed', 'checkout.session.async_payment_succeeded'}:
|
|
_mark_order_paid_from_stripe(order, session)
|
|
elif order and event_type == 'checkout.session.expired' and order.payment_status != 'Paid':
|
|
_save_order_updates(order, payment_status='Failed')
|
|
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
@login_required
|
|
def my_orders(request):
|
|
status_filter = request.GET.get('status', '').strip()
|
|
payment_filter = request.GET.get('payment', '').strip()
|
|
|
|
valid_statuses = [choice[0] for choice in Order.STATUS_CHOICES]
|
|
valid_payment_statuses = [choice[0] for choice in Order.PAYMENT_STATUS_CHOICES]
|
|
|
|
if status_filter not in valid_statuses:
|
|
status_filter = ''
|
|
if payment_filter not in valid_payment_statuses:
|
|
payment_filter = ''
|
|
|
|
base_orders = Order.objects.filter(user=request.user)
|
|
orders = base_orders.prefetch_related('items__product').order_by('-created_at')
|
|
|
|
if status_filter:
|
|
orders = orders.filter(status=status_filter)
|
|
if payment_filter:
|
|
orders = orders.filter(payment_status=payment_filter)
|
|
|
|
order_summary = {
|
|
'total': base_orders.count(),
|
|
'paid': base_orders.filter(payment_status='Paid').count(),
|
|
'pending_payment': base_orders.exclude(payment_status='Paid').count(),
|
|
'shipped': base_orders.filter(status='Shipped').count(),
|
|
'delivered': base_orders.filter(status='Delivered').count(),
|
|
}
|
|
|
|
return render(
|
|
request,
|
|
'order/my_orders.html',
|
|
{
|
|
'orders': orders,
|
|
'order_summary': order_summary,
|
|
'status_filters': valid_statuses,
|
|
'payment_filters': valid_payment_statuses,
|
|
'selected_status': status_filter,
|
|
'selected_payment_status': payment_filter,
|
|
'has_order_filters': bool(status_filter or payment_filter),
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def order_detail(request, order_id):
|
|
order = get_object_or_404(Order.objects.prefetch_related('items__product'), id=order_id, user=request.user)
|
|
timeline = _build_timeline(order.status)
|
|
return render(request, 'order/order_detail.html', {'order': order, 'timeline': timeline})
|