394 lines
16 KiB
Python
394 lines
16 KiB
Python
import logging
|
|
import hashlib
|
|
import hmac
|
|
import base64
|
|
import urllib.parse
|
|
from typing import Optional, Dict, Any
|
|
from datetime import datetime
|
|
from ...shared.config.settings import settings
|
|
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
|
from ...bookings.models.booking import Booking, BookingStatus
|
|
from ...system.models.system_settings import SystemSettings
|
|
from sqlalchemy.orm import Session
|
|
import os
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def get_borica_terminal_id(db: Session) -> Optional[str]:
|
|
try:
|
|
setting = db.query(SystemSettings).filter(SystemSettings.key == 'borica_terminal_id').first()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
except Exception:
|
|
pass
|
|
return settings.BORICA_TERMINAL_ID if settings.BORICA_TERMINAL_ID else None
|
|
|
|
def get_borica_merchant_id(db: Session) -> Optional[str]:
|
|
try:
|
|
setting = db.query(SystemSettings).filter(SystemSettings.key == 'borica_merchant_id').first()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
except Exception:
|
|
pass
|
|
return settings.BORICA_MERCHANT_ID if settings.BORICA_MERCHANT_ID else None
|
|
|
|
def get_borica_private_key_path(db: Session) -> Optional[str]:
|
|
try:
|
|
setting = db.query(SystemSettings).filter(SystemSettings.key == 'borica_private_key_path').first()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
except Exception:
|
|
pass
|
|
return settings.BORICA_PRIVATE_KEY_PATH if settings.BORICA_PRIVATE_KEY_PATH else None
|
|
|
|
def get_borica_certificate_path(db: Session) -> Optional[str]:
|
|
try:
|
|
setting = db.query(SystemSettings).filter(SystemSettings.key == 'borica_certificate_path').first()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
except Exception:
|
|
pass
|
|
return settings.BORICA_CERTIFICATE_PATH if settings.BORICA_CERTIFICATE_PATH else None
|
|
|
|
def get_borica_gateway_url(db: Session) -> str:
|
|
try:
|
|
setting = db.query(SystemSettings).filter(SystemSettings.key == 'borica_gateway_url').first()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
except Exception:
|
|
pass
|
|
mode = get_borica_mode(db)
|
|
if mode == 'production':
|
|
return settings.BORICA_GATEWAY_URL.replace('dev', 'gate') if 'dev' in settings.BORICA_GATEWAY_URL else settings.BORICA_GATEWAY_URL
|
|
return settings.BORICA_GATEWAY_URL
|
|
|
|
def get_borica_mode(db: Session) -> str:
|
|
try:
|
|
setting = db.query(SystemSettings).filter(SystemSettings.key == 'borica_mode').first()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
except Exception:
|
|
pass
|
|
return settings.BORICA_MODE if settings.BORICA_MODE else 'test'
|
|
|
|
class BoricaService:
|
|
"""
|
|
Borica payment gateway service for processing online payments.
|
|
Borica is the Bulgarian payment gateway system.
|
|
"""
|
|
|
|
@staticmethod
|
|
def generate_transaction_id(booking_id: int) -> str:
|
|
"""Generate a unique transaction ID for Borica"""
|
|
timestamp = datetime.utcnow().strftime('%Y%m%d%H%M%S')
|
|
return f"BOOK{booking_id:06d}{timestamp}"
|
|
|
|
@staticmethod
|
|
def create_payment_request(
|
|
amount: float,
|
|
currency: str,
|
|
order_id: str,
|
|
description: str,
|
|
return_url: str,
|
|
db: Optional[Session] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a Borica payment request.
|
|
Returns the payment form data that needs to be submitted to Borica gateway.
|
|
"""
|
|
terminal_id = None
|
|
merchant_id = None
|
|
gateway_url = None
|
|
|
|
if db:
|
|
terminal_id = get_borica_terminal_id(db)
|
|
merchant_id = get_borica_merchant_id(db)
|
|
gateway_url = get_borica_gateway_url(db)
|
|
|
|
if not terminal_id:
|
|
terminal_id = settings.BORICA_TERMINAL_ID
|
|
if not merchant_id:
|
|
merchant_id = settings.BORICA_MERCHANT_ID
|
|
if not gateway_url:
|
|
gateway_url = get_borica_gateway_url(db) if db else settings.BORICA_GATEWAY_URL
|
|
|
|
if not terminal_id or not merchant_id:
|
|
raise ValueError('Borica Terminal ID and Merchant ID are required')
|
|
|
|
# Convert amount to minor units (cents/stotinki)
|
|
amount_minor = int(round(amount * 100))
|
|
|
|
# Format amount as string with leading zeros (12 digits)
|
|
amount_str = f"{amount_minor:012d}"
|
|
|
|
# Create transaction timestamp (YYYYMMDDHHMMSS)
|
|
transaction_timestamp = datetime.utcnow().strftime('%Y%m%d%H%M%S')
|
|
|
|
# Create order description (max 125 chars)
|
|
order_description = description[:125] if description else f"Booking Payment {order_id}"
|
|
|
|
# Create signature data string
|
|
# Format: TERMINAL=TERMINAL_ID,TRTYPE=1,ORDER=ORDER_ID,AMOUNT=AMOUNT,TIMESTAMP=TIMESTAMP
|
|
signature_data = f"TERMINAL={terminal_id},TRTYPE=1,ORDER={order_id},AMOUNT={amount_str},TIMESTAMP={transaction_timestamp}"
|
|
|
|
# For test mode, we'll use a simple HMAC signature
|
|
# In production, you would use the private key to sign
|
|
private_key_path = get_borica_private_key_path(db) if db else settings.BORICA_PRIVATE_KEY_PATH
|
|
|
|
# Generate signature (simplified for testing - in production use proper certificate signing)
|
|
signature = BoricaService._generate_signature(signature_data, private_key_path)
|
|
|
|
return {
|
|
'terminal_id': terminal_id,
|
|
'merchant_id': merchant_id,
|
|
'order_id': order_id,
|
|
'amount': amount_str,
|
|
'currency': currency.upper(),
|
|
'description': order_description,
|
|
'timestamp': transaction_timestamp,
|
|
'signature': signature,
|
|
'gateway_url': gateway_url,
|
|
'return_url': return_url,
|
|
'trtype': '1' # Sale transaction
|
|
}
|
|
|
|
@staticmethod
|
|
def _generate_signature(data: str, private_key_path: Optional[str] = None) -> str:
|
|
"""
|
|
Generate signature for Borica request.
|
|
In production, this should use the actual private key certificate.
|
|
For testing, we'll use a simple hash.
|
|
"""
|
|
if private_key_path and os.path.exists(private_key_path):
|
|
try:
|
|
# In production, you would load the private key and sign the data
|
|
# This is a simplified version for testing
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
with open(private_key_path, 'rb') as key_file:
|
|
private_key = serialization.load_pem_private_key(
|
|
key_file.read(),
|
|
password=None,
|
|
backend=default_backend()
|
|
)
|
|
|
|
# NOTE: SHA1 is required by Borica payment gateway protocol
|
|
# This is a known security trade-off required for payment gateway compatibility
|
|
# Monitor for Borica protocol updates that support stronger algorithms
|
|
signature = private_key.sign(
|
|
data.encode('utf-8'),
|
|
padding.PKCS1v15(),
|
|
hashes.SHA1() # nosec B303 # Required by Borica protocol - acceptable risk
|
|
)
|
|
return base64.b64encode(signature).decode('utf-8')
|
|
except Exception as e:
|
|
logger.warning(f'Failed to sign with private key, using test signature: {e}')
|
|
|
|
# Fallback: simple hash for testing
|
|
return hashlib.sha256(data.encode('utf-8')).hexdigest()[:40]
|
|
|
|
@staticmethod
|
|
def verify_response_signature(response_data: Dict[str, Any], db: Optional[Session] = None) -> bool:
|
|
"""
|
|
Verify the signature of a Borica response.
|
|
"""
|
|
try:
|
|
# Extract signature from response
|
|
signature = response_data.get('P_SIGN', '')
|
|
if not signature:
|
|
return False
|
|
|
|
# Reconstruct signature data from response
|
|
terminal_id = response_data.get('TERMINAL', '')
|
|
trtype = response_data.get('TRTYPE', '')
|
|
order_id = response_data.get('ORDER', '')
|
|
amount = response_data.get('AMOUNT', '')
|
|
timestamp = response_data.get('TIMESTAMP', '')
|
|
nonce = response_data.get('NONCE', '')
|
|
rcode = response_data.get('RC', '')
|
|
|
|
# Build signature string
|
|
signature_data = f"TERMINAL={terminal_id},TRTYPE={trtype},ORDER={order_id},AMOUNT={amount},TIMESTAMP={timestamp},NONCE={nonce},RC={rcode}"
|
|
|
|
# Verify signature using certificate
|
|
certificate_path = get_borica_certificate_path(db) if db else settings.BORICA_CERTIFICATE_PATH
|
|
|
|
if certificate_path and os.path.exists(certificate_path):
|
|
try:
|
|
from cryptography import x509
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
|
|
with open(certificate_path, 'rb') as cert_file:
|
|
cert = x509.load_pem_x509_certificate(
|
|
cert_file.read(),
|
|
default_backend()
|
|
)
|
|
|
|
public_key = cert.public_key()
|
|
signature_bytes = base64.b64decode(signature)
|
|
|
|
# NOTE: SHA1 is required by Borica payment gateway protocol
|
|
# This is a known security trade-off required for payment gateway compatibility
|
|
public_key.verify(
|
|
signature_bytes,
|
|
signature_data.encode('utf-8'),
|
|
padding.PKCS1v15(),
|
|
hashes.SHA1() # nosec B303 # Required by Borica protocol - acceptable risk
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'Signature verification failed: {e}')
|
|
return False
|
|
|
|
# For testing, accept if signature exists
|
|
return bool(signature)
|
|
except Exception as e:
|
|
logger.error(f'Error verifying signature: {e}')
|
|
return False
|
|
|
|
@staticmethod
|
|
async def confirm_payment(
|
|
response_data: Dict[str, Any],
|
|
db: Session,
|
|
booking_id: Optional[int] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Confirm a Borica payment from the response data.
|
|
"""
|
|
try:
|
|
# Verify signature
|
|
if not BoricaService.verify_response_signature(response_data, db):
|
|
raise ValueError('Invalid payment response signature')
|
|
|
|
# Extract response data
|
|
order_id = response_data.get('ORDER', '')
|
|
amount_str = response_data.get('AMOUNT', '')
|
|
rcode = response_data.get('RC', '')
|
|
rcode_msg = response_data.get('RCTEXT', '')
|
|
|
|
# Convert amount from minor units
|
|
amount = float(amount_str) / 100 if amount_str else 0.0
|
|
|
|
# Get booking ID from order_id if not provided
|
|
if not booking_id and order_id:
|
|
# Extract booking ID from order_id (format: BOOK{booking_id}{timestamp})
|
|
try:
|
|
if order_id.startswith('BOOK'):
|
|
booking_id = int(order_id[4:10])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
if not booking_id:
|
|
raise ValueError('Booking ID is required')
|
|
|
|
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
|
if not booking:
|
|
raise ValueError('Booking not found')
|
|
|
|
# Check response code (00 = success)
|
|
if rcode != '00':
|
|
# Payment failed
|
|
payment = db.query(Payment).filter(
|
|
Payment.booking_id == booking_id,
|
|
Payment.transaction_id == order_id,
|
|
Payment.payment_method == PaymentMethod.borica
|
|
).first()
|
|
|
|
if payment:
|
|
payment.payment_status = PaymentStatus.failed
|
|
payment.notes = f'Borica payment failed: {rcode} - {rcode_msg}'
|
|
db.commit()
|
|
db.refresh(payment)
|
|
|
|
raise ValueError(f'Payment failed: {rcode} - {rcode_msg}')
|
|
|
|
# Payment successful
|
|
payment = db.query(Payment).filter(
|
|
Payment.booking_id == booking_id,
|
|
Payment.transaction_id == order_id,
|
|
Payment.payment_method == PaymentMethod.borica
|
|
).first()
|
|
|
|
if not payment:
|
|
payment = db.query(Payment).filter(
|
|
Payment.booking_id == booking_id,
|
|
Payment.payment_method == PaymentMethod.borica,
|
|
Payment.payment_status == PaymentStatus.pending
|
|
).order_by(Payment.created_at.desc()).first()
|
|
|
|
if payment:
|
|
payment.payment_status = PaymentStatus.completed
|
|
payment.payment_date = datetime.utcnow()
|
|
payment.amount = amount
|
|
payment.transaction_id = order_id
|
|
payment.notes = f'Borica payment completed: {rcode_msg}'
|
|
else:
|
|
payment_type = PaymentType.full
|
|
if booking.requires_deposit and not booking.deposit_paid:
|
|
payment_type = PaymentType.deposit
|
|
|
|
payment = Payment(
|
|
booking_id=booking_id,
|
|
amount=amount,
|
|
payment_method=PaymentMethod.borica,
|
|
payment_type=payment_type,
|
|
payment_status=PaymentStatus.completed,
|
|
transaction_id=order_id,
|
|
payment_date=datetime.utcnow(),
|
|
notes=f'Borica payment completed: {rcode_msg}'
|
|
)
|
|
db.add(payment)
|
|
|
|
db.commit()
|
|
db.refresh(payment)
|
|
|
|
# Update booking status if payment completed
|
|
if payment.payment_status == PaymentStatus.completed:
|
|
db.refresh(booking)
|
|
total_paid = sum(
|
|
float(p.amount) for p in booking.payments
|
|
if p.payment_status == PaymentStatus.completed
|
|
)
|
|
|
|
if payment.payment_type == PaymentType.deposit:
|
|
booking.deposit_paid = True
|
|
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
|
booking.status = BookingStatus.confirmed
|
|
elif payment.payment_type == PaymentType.full:
|
|
if total_paid >= float(booking.total_price):
|
|
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
|
booking.status = BookingStatus.confirmed
|
|
|
|
db.commit()
|
|
db.refresh(booking)
|
|
|
|
def get_enum_value(enum_obj):
|
|
if enum_obj is None:
|
|
return None
|
|
if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)):
|
|
return enum_obj.value
|
|
return enum_obj
|
|
|
|
return {
|
|
'id': payment.id,
|
|
'booking_id': payment.booking_id,
|
|
'amount': float(payment.amount) if payment.amount else 0.0,
|
|
'payment_method': get_enum_value(payment.payment_method),
|
|
'payment_type': get_enum_value(payment.payment_type),
|
|
'payment_status': get_enum_value(payment.payment_status),
|
|
'transaction_id': payment.transaction_id,
|
|
'payment_date': payment.payment_date.isoformat() if payment.payment_date else None
|
|
}
|
|
except ValueError as e:
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f'Error confirming Borica payment: {e}', exc_info=True)
|
|
db.rollback()
|
|
raise ValueError(f'Error confirming payment: {str(e)}')
|
|
|