Files
Hotel-Booking/Backend/src/payments/services/borica_service.py
Iliyan Angelov 62c1fe5951 updates
2025-12-01 06:50:10 +02:00

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