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)}')