498 lines
17 KiB
Python
498 lines
17 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import uuid
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode, urljoin
|
|
from urllib.request import Request, urlopen
|
|
|
|
from django.conf import settings
|
|
from django.urls import reverse
|
|
|
|
import stripe
|
|
|
|
ZERO_DECIMAL_CURRENCIES = {
|
|
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'
|
|
}
|
|
|
|
|
|
class PaymentGatewayError(RuntimeError):
|
|
def __init__(self, message, *, details=''):
|
|
super().__init__(message)
|
|
self.details = details
|
|
|
|
|
|
def payment_currency():
|
|
return getattr(settings, 'PAYMENT_CURRENCY', 'NPR').upper()
|
|
|
|
|
|
def stripe_configured():
|
|
return bool(settings.STRIPE_SECRET_KEY)
|
|
|
|
|
|
def esewa_configured():
|
|
return all(
|
|
[
|
|
getattr(settings, 'ESEWA_PRODUCT_CODE', '').strip(),
|
|
getattr(settings, 'ESEWA_SECRET_KEY', '').strip(),
|
|
getattr(settings, 'ESEWA_FORM_URL', '').strip(),
|
|
getattr(settings, 'ESEWA_STATUS_URL', '').strip(),
|
|
]
|
|
)
|
|
|
|
|
|
def khalti_configured():
|
|
return all(
|
|
[
|
|
getattr(settings, 'KHALTI_SECRET_KEY', '').strip(),
|
|
getattr(settings, 'KHALTI_INITIATE_URL', '').strip(),
|
|
getattr(settings, 'KHALTI_LOOKUP_URL', '').strip(),
|
|
]
|
|
)
|
|
|
|
|
|
def stripe_value(obj, key, default=None):
|
|
if obj is None:
|
|
return default
|
|
if isinstance(obj, dict):
|
|
return obj.get(key, default)
|
|
getter = getattr(obj, 'get', None)
|
|
if callable(getter):
|
|
return getter(key, default)
|
|
return getattr(obj, key, default)
|
|
|
|
|
|
def _public_base_url(request):
|
|
if settings.PUBLIC_APP_URL:
|
|
return settings.PUBLIC_APP_URL
|
|
|
|
forwarded_proto = request.META.get('HTTP_X_FORWARDED_PROTO', '').split(',')[0].strip()
|
|
scheme = forwarded_proto or request.scheme or 'http'
|
|
host = request.get_host()
|
|
if host.endswith('.flatlogic.app'):
|
|
scheme = 'https'
|
|
return f'{scheme}://{host}'
|
|
|
|
|
|
def absolute_url(request, path):
|
|
return urljoin(f"{_public_base_url(request)}/", path.lstrip('/'))
|
|
|
|
|
|
def _format_amount(amount):
|
|
value = Decimal(amount).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
|
return format(value, 'f')
|
|
|
|
|
|
def _to_minor_units(amount, currency):
|
|
value = Decimal(amount)
|
|
if currency.lower() in ZERO_DECIMAL_CURRENCIES:
|
|
return int(value.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
|
|
return int((value * Decimal('100')).quantize(Decimal('1'), rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def _configure_stripe():
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
|
|
def _line_items_for_order(order):
|
|
currency = settings.STRIPE_CURRENCY.lower()
|
|
line_items = []
|
|
for item in order.items.select_related('product').all():
|
|
line_items.append(
|
|
{
|
|
'price_data': {
|
|
'currency': currency,
|
|
'unit_amount': _to_minor_units(item.price, currency),
|
|
'product_data': {
|
|
'name': item.product.name,
|
|
'description': item.product.description[:500],
|
|
},
|
|
},
|
|
'quantity': int(item.quantity),
|
|
}
|
|
)
|
|
return line_items
|
|
|
|
|
|
def create_checkout_session(request, order):
|
|
if not stripe_configured():
|
|
raise RuntimeError('Stripe is not configured.')
|
|
|
|
_configure_stripe()
|
|
success_path = reverse('success')
|
|
cancel_path = reverse('payment', kwargs={'order_id': order.id})
|
|
success_url = f"{absolute_url(request, success_path)}?order_id={order.id}&session_id={{CHECKOUT_SESSION_ID}}"
|
|
cancel_url = f"{absolute_url(request, cancel_path)}?cancelled=1"
|
|
|
|
payload = {
|
|
'mode': 'payment',
|
|
'line_items': _line_items_for_order(order),
|
|
'success_url': success_url,
|
|
'cancel_url': cancel_url,
|
|
'client_reference_id': str(order.id),
|
|
'metadata': {
|
|
'order_id': str(order.id),
|
|
'user_id': str(order.user_id),
|
|
},
|
|
'payment_method_types': ['card'],
|
|
'billing_address_collection': 'auto',
|
|
'submit_type': 'pay',
|
|
}
|
|
|
|
if order.user.email:
|
|
payload['customer_email'] = order.user.email
|
|
|
|
return stripe.checkout.Session.create(**payload)
|
|
|
|
|
|
def retrieve_checkout_session(session_id):
|
|
if not stripe_configured():
|
|
raise RuntimeError('Stripe is not configured.')
|
|
_configure_stripe()
|
|
return stripe.checkout.Session.retrieve(session_id)
|
|
|
|
|
|
def construct_webhook_event(payload, signature):
|
|
if not stripe_configured() or not settings.STRIPE_WEBHOOK_SECRET:
|
|
raise RuntimeError('Stripe webhook is not configured.')
|
|
_configure_stripe()
|
|
return stripe.Webhook.construct_event(payload, signature, settings.STRIPE_WEBHOOK_SECRET)
|
|
|
|
|
|
def _extract_error_message(body):
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return (body or 'Unknown gateway error.').strip()[:300]
|
|
|
|
if isinstance(payload, dict):
|
|
if payload.get('detail'):
|
|
return str(payload['detail'])
|
|
if payload.get('error_message'):
|
|
return str(payload['error_message'])
|
|
for key, value in payload.items():
|
|
if isinstance(value, list) and value:
|
|
return f"{key}: {value[0]}"
|
|
return json.dumps(payload)[:300]
|
|
|
|
return str(payload)[:300]
|
|
|
|
|
|
def _json_request(url, *, method='GET', payload=None, headers=None, timeout=20):
|
|
request_headers = {
|
|
'Accept': 'application/json',
|
|
}
|
|
data = None
|
|
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode('utf-8')
|
|
request_headers['Content-Type'] = 'application/json'
|
|
|
|
if headers:
|
|
request_headers.update(headers)
|
|
|
|
request = Request(url, data=data, headers=request_headers, method=method)
|
|
|
|
try:
|
|
with urlopen(request, timeout=timeout) as response:
|
|
body = response.read().decode('utf-8')
|
|
except HTTPError as exc:
|
|
error_body = exc.read().decode('utf-8', errors='replace')
|
|
raise PaymentGatewayError(
|
|
_extract_error_message(error_body) or f'Gateway request failed with HTTP {exc.code}.',
|
|
details=error_body,
|
|
) from exc
|
|
except URLError as exc:
|
|
raise PaymentGatewayError('Gateway request could not be completed.', details=str(exc.reason)) from exc
|
|
|
|
if not body:
|
|
return {}
|
|
|
|
try:
|
|
return json.loads(body)
|
|
except json.JSONDecodeError as exc:
|
|
raise PaymentGatewayError('Gateway returned an invalid response.', details=body[:500]) from exc
|
|
|
|
|
|
def _signed_field_names_list(signed_field_names):
|
|
return [field.strip() for field in str(signed_field_names).split(',') if field and field.strip()]
|
|
|
|
|
|
def _signed_message(payload, signed_field_names):
|
|
parts = []
|
|
for field_name in _signed_field_names_list(signed_field_names):
|
|
if field_name not in payload:
|
|
raise PaymentGatewayError(f'Missing signed field: {field_name}')
|
|
parts.append(f'{field_name}={payload[field_name]}')
|
|
return ','.join(parts)
|
|
|
|
|
|
def _hmac_sha256_base64(secret_key, message):
|
|
digest = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).digest()
|
|
return base64.b64encode(digest).decode('utf-8')
|
|
|
|
|
|
def _first_present(payload, *keys):
|
|
for key in keys:
|
|
value = payload.get(key)
|
|
if value not in (None, ''):
|
|
return value
|
|
return ''
|
|
|
|
|
|
def build_esewa_redirect(request, order):
|
|
if not esewa_configured():
|
|
raise PaymentGatewayError('eSewa is not configured yet.')
|
|
|
|
transaction_uuid = f"ORD-{order.id}-{uuid.uuid4().hex[:12].upper()}"
|
|
total_amount = _format_amount(order.total_price)
|
|
success_url = f"{absolute_url(request, reverse('esewa_return'))}?order_id={order.id}"
|
|
failure_url = f"{absolute_url(request, reverse('esewa_return'))}?order_id={order.id}&failed=1"
|
|
|
|
fields = {
|
|
'amount': total_amount,
|
|
'tax_amount': '0',
|
|
'total_amount': total_amount,
|
|
'transaction_uuid': transaction_uuid,
|
|
'product_code': settings.ESEWA_PRODUCT_CODE,
|
|
'product_service_charge': '0',
|
|
'product_delivery_charge': '0',
|
|
'success_url': success_url,
|
|
'failure_url': failure_url,
|
|
'signed_field_names': 'total_amount,transaction_uuid,product_code',
|
|
}
|
|
fields['signature'] = _hmac_sha256_base64(
|
|
settings.ESEWA_SECRET_KEY,
|
|
_signed_message(fields, fields['signed_field_names']),
|
|
)
|
|
|
|
return {
|
|
'action_url': settings.ESEWA_FORM_URL,
|
|
'fields': fields,
|
|
'session_id': transaction_uuid,
|
|
'currency': payment_currency(),
|
|
}
|
|
|
|
|
|
def _decode_esewa_callback(encoded_data):
|
|
if not encoded_data:
|
|
raise PaymentGatewayError('eSewa did not return any payment payload.')
|
|
|
|
padded = encoded_data + ('=' * (-len(encoded_data) % 4))
|
|
try:
|
|
raw = base64.b64decode(padded)
|
|
except Exception as exc:
|
|
raise PaymentGatewayError('eSewa returned an unreadable payment payload.') from exc
|
|
|
|
try:
|
|
payload = json.loads(raw.decode('utf-8'))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise PaymentGatewayError('eSewa returned an invalid payment payload.') from exc
|
|
|
|
if not isinstance(payload, dict):
|
|
raise PaymentGatewayError('eSewa returned an unexpected payment payload.')
|
|
|
|
return {str(key): '' if value is None else str(value) for key, value in payload.items()}
|
|
|
|
|
|
def _verify_esewa_callback_signature(payload):
|
|
signature = payload.get('signature', '').strip()
|
|
signed_field_names = payload.get('signed_field_names', '').strip()
|
|
|
|
if not signature or not signed_field_names:
|
|
raise PaymentGatewayError('eSewa callback signature is missing.')
|
|
|
|
expected_signature = _hmac_sha256_base64(
|
|
settings.ESEWA_SECRET_KEY,
|
|
_signed_message(payload, signed_field_names),
|
|
)
|
|
|
|
if not hmac.compare_digest(expected_signature, signature):
|
|
raise PaymentGatewayError('eSewa callback signature could not be verified.')
|
|
|
|
|
|
def _esewa_status_lookup(order):
|
|
params = urlencode(
|
|
{
|
|
'product_code': settings.ESEWA_PRODUCT_CODE,
|
|
'total_amount': _format_amount(order.total_price),
|
|
'transaction_uuid': order.payment_session_id,
|
|
}
|
|
)
|
|
base_url = settings.ESEWA_STATUS_URL.rstrip('/') + '/'
|
|
return _json_request(f'{base_url}?{params}')
|
|
|
|
|
|
def verify_esewa_payment(order, encoded_data):
|
|
if not esewa_configured():
|
|
raise PaymentGatewayError('eSewa is not configured yet.')
|
|
|
|
callback = _decode_esewa_callback(encoded_data.strip())
|
|
_verify_esewa_callback_signature(callback)
|
|
|
|
transaction_uuid = callback.get('transaction_uuid', '').strip()
|
|
if not transaction_uuid:
|
|
raise PaymentGatewayError('eSewa did not return a transaction id.')
|
|
|
|
if transaction_uuid != (order.payment_session_id or '').strip():
|
|
raise PaymentGatewayError('The returned eSewa session did not match the latest payment attempt for this order.')
|
|
|
|
callback_total = _format_amount(callback.get('total_amount', order.total_price))
|
|
order_total = _format_amount(order.total_price)
|
|
if callback_total != order_total:
|
|
raise PaymentGatewayError('The returned eSewa amount did not match this order total.')
|
|
|
|
callback_product_code = callback.get('product_code', '').strip()
|
|
if callback_product_code and callback_product_code != settings.ESEWA_PRODUCT_CODE:
|
|
raise PaymentGatewayError('The returned eSewa product code did not match the configured merchant code.')
|
|
|
|
status_response = _esewa_status_lookup(order)
|
|
|
|
if status_response.get('error_message'):
|
|
raise PaymentGatewayError(f"eSewa status lookup failed: {status_response['error_message']}")
|
|
|
|
gateway_status = str(status_response.get('status', '')).upper().strip()
|
|
if not gateway_status:
|
|
raise PaymentGatewayError('eSewa did not return a payment status for this transaction.')
|
|
|
|
returned_total = _first_present(status_response, 'total_amount', 'totalAmount')
|
|
if returned_total and _format_amount(returned_total) != order_total:
|
|
raise PaymentGatewayError('The verified eSewa amount did not match this order total.')
|
|
|
|
returned_product_code = str(_first_present(status_response, 'product_code', 'productCode', 'scd')).strip()
|
|
if returned_product_code and returned_product_code != settings.ESEWA_PRODUCT_CODE:
|
|
raise PaymentGatewayError('The verified eSewa merchant code did not match this order.')
|
|
|
|
reference = str(_first_present(status_response, 'ref_id', 'refId') or callback.get('transaction_code') or transaction_uuid).strip()
|
|
|
|
if gateway_status == 'COMPLETE':
|
|
local_status = 'Paid'
|
|
message = 'eSewa payment verified successfully.'
|
|
elif gateway_status in {'PENDING', 'AMBIGUOUS'}:
|
|
local_status = 'Pending'
|
|
message = 'eSewa is still processing this payment.'
|
|
else:
|
|
local_status = 'Failed'
|
|
message = f'eSewa returned {gateway_status} for this payment.'
|
|
|
|
return {
|
|
'status': local_status,
|
|
'gateway_status': gateway_status,
|
|
'reference': reference,
|
|
'session_id': transaction_uuid,
|
|
'currency': payment_currency(),
|
|
'message': message,
|
|
}
|
|
|
|
|
|
def initiate_khalti_payment(request, order):
|
|
if not khalti_configured():
|
|
raise PaymentGatewayError('Khalti is not configured yet.')
|
|
|
|
amount_in_paisa = _to_minor_units(order.total_price, payment_currency())
|
|
if amount_in_paisa < 1000:
|
|
raise PaymentGatewayError('Khalti requires a minimum payment amount of Rs. 10.')
|
|
|
|
purchase_order_id = f"ORDER-{order.id}-{uuid.uuid4().hex[:8].upper()}"
|
|
payload = {
|
|
'return_url': f"{absolute_url(request, reverse('khalti_return'))}?order_id={order.id}",
|
|
'website_url': _public_base_url(request),
|
|
'amount': amount_in_paisa,
|
|
'purchase_order_id': purchase_order_id,
|
|
'purchase_order_name': f'Order #{order.id}',
|
|
}
|
|
|
|
customer_info = {
|
|
'name': order.full_name or order.user.get_full_name() or order.user.username,
|
|
'phone': order.phone,
|
|
}
|
|
if order.user.email:
|
|
customer_info['email'] = order.user.email
|
|
payload['customer_info'] = {key: value for key, value in customer_info.items() if value}
|
|
|
|
response = _json_request(
|
|
settings.KHALTI_INITIATE_URL,
|
|
method='POST',
|
|
payload=payload,
|
|
headers={'Authorization': f'key {settings.KHALTI_SECRET_KEY}'},
|
|
)
|
|
|
|
payment_url = str(response.get('payment_url', '')).strip()
|
|
pidx = str(response.get('pidx', '')).strip()
|
|
|
|
if not payment_url or not pidx:
|
|
raise PaymentGatewayError('Khalti did not return a payment URL for this order.')
|
|
|
|
return {
|
|
'payment_url': payment_url,
|
|
'session_id': pidx,
|
|
'reference': purchase_order_id,
|
|
'currency': payment_currency(),
|
|
}
|
|
|
|
|
|
def _khalti_lookup(pidx):
|
|
return _json_request(
|
|
settings.KHALTI_LOOKUP_URL,
|
|
method='POST',
|
|
payload={'pidx': pidx},
|
|
headers={'Authorization': f'key {settings.KHALTI_SECRET_KEY}'},
|
|
)
|
|
|
|
|
|
def verify_khalti_payment(order, pidx):
|
|
if not khalti_configured():
|
|
raise PaymentGatewayError('Khalti is not configured yet.')
|
|
|
|
normalized_pidx = (pidx or '').strip()
|
|
if not normalized_pidx:
|
|
raise PaymentGatewayError('Khalti did not return a payment session id.')
|
|
|
|
if normalized_pidx != (order.payment_session_id or '').strip():
|
|
raise PaymentGatewayError('The returned Khalti session did not match the latest payment attempt for this order.')
|
|
|
|
lookup = _khalti_lookup(normalized_pidx)
|
|
gateway_status = str(lookup.get('status', '')).strip()
|
|
if not gateway_status:
|
|
raise PaymentGatewayError('Khalti did not return a payment status for this transaction.')
|
|
|
|
returned_amount = lookup.get('total_amount')
|
|
if returned_amount not in (None, ''):
|
|
try:
|
|
returned_amount_value = int(returned_amount)
|
|
except (TypeError, ValueError) as exc:
|
|
raise PaymentGatewayError('Khalti returned an invalid payment amount.') from exc
|
|
|
|
expected_amount = _to_minor_units(order.total_price, payment_currency())
|
|
if returned_amount_value != expected_amount:
|
|
raise PaymentGatewayError('The returned Khalti amount did not match this order total.')
|
|
|
|
if order.payment_reference:
|
|
returned_order_reference = str(lookup.get('purchase_order_id', '')).strip()
|
|
if returned_order_reference and returned_order_reference != order.payment_reference:
|
|
raise PaymentGatewayError('The returned Khalti purchase order id did not match this order.')
|
|
|
|
refunded = bool(lookup.get('refunded'))
|
|
transaction_reference = str(lookup.get('transaction_id') or normalized_pidx).strip()
|
|
|
|
if gateway_status == 'Completed' and not refunded:
|
|
local_status = 'Paid'
|
|
message = 'Khalti payment verified successfully.'
|
|
elif gateway_status in {'Pending', 'Initiated'}:
|
|
local_status = 'Pending'
|
|
message = 'Khalti is still processing this payment.'
|
|
else:
|
|
local_status = 'Failed'
|
|
message = f'Khalti returned {gateway_status} for this payment.'
|
|
|
|
return {
|
|
'status': local_status,
|
|
'gateway_status': gateway_status,
|
|
'reference': transaction_reference,
|
|
'session_id': normalized_pidx,
|
|
'currency': payment_currency(),
|
|
'message': message,
|
|
}
|