update
This commit is contained in:
388
Backend/src/payments/services/borica_service.py
Normal file
388
Backend/src/payments/services/borica_service.py
Normal file
@@ -0,0 +1,388 @@
|
||||
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()
|
||||
)
|
||||
|
||||
signature = private_key.sign(
|
||||
data.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA1()
|
||||
)
|
||||
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)
|
||||
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
signature_data.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA1()
|
||||
)
|
||||
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)}')
|
||||
|
||||
Reference in New Issue
Block a user