This commit is contained in:
Iliyan Angelov
2025-11-30 22:43:09 +02:00
parent 24b40450dd
commit 39fcfff811
1610 changed files with 5442 additions and 1383 deletions

View File

View File

View File

@@ -0,0 +1,78 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Boolean, Index
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ...shared.config.database import Base
class InvoiceStatus(str, enum.Enum):
draft = 'draft'
sent = 'sent'
paid = 'paid'
overdue = 'overdue'
cancelled = 'cancelled'
class Invoice(Base):
__tablename__ = 'invoices'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
invoice_number = Column(String(50), unique=True, nullable=False, index=True)
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
issue_date = Column(DateTime, default=datetime.utcnow, nullable=False)
due_date = Column(DateTime, nullable=False)
paid_date = Column(DateTime, nullable=True)
subtotal = Column(Numeric(10, 2), nullable=False, default=0.0)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.0)
tax_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
total_amount = Column(Numeric(10, 2), nullable=False)
amount_paid = Column(Numeric(10, 2), nullable=False, default=0.0)
balance_due = Column(Numeric(10, 2), nullable=False)
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
is_proforma = Column(Boolean, nullable=False, default=False)
company_name = Column(String(200), nullable=True)
company_address = Column(Text, nullable=True)
company_phone = Column(String(50), nullable=True)
company_email = Column(String(100), nullable=True)
company_tax_id = Column(String(100), nullable=True)
company_logo_url = Column(String(500), nullable=True)
customer_name = Column(String(200), nullable=False)
customer_email = Column(String(100), nullable=False)
customer_address = Column(Text, nullable=True)
customer_phone = Column(String(50), nullable=True)
customer_tax_id = Column(String(100), nullable=True)
notes = Column(Text, nullable=True)
terms_and_conditions = Column(Text, nullable=True)
payment_instructions = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
booking = relationship('Booking', back_populates='invoices')
user = relationship('User', foreign_keys=[user_id], backref='invoices')
created_by = relationship('User', foreign_keys=[created_by_id])
updated_by = relationship('User', foreign_keys=[updated_by_id])
items = relationship('InvoiceItem', back_populates='invoice', cascade='all, delete-orphan')
# Index for invoice status and date queries
__table_args__ = (
Index('idx_invoice_status', 'status'),
Index('idx_invoice_user_status', 'user_id', 'status'),
Index('idx_invoice_due_date', 'due_date'),
)
class InvoiceItem(Base):
__tablename__ = 'invoice_items'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
invoice_id = Column(Integer, ForeignKey('invoices.id'), nullable=False)
description = Column(String(500), nullable=False)
quantity = Column(Numeric(10, 2), nullable=False, default=1.0)
unit_price = Column(Numeric(10, 2), nullable=False)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.0)
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
line_total = Column(Numeric(10, 2), nullable=False)
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
service_id = Column(Integer, ForeignKey('services.id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
invoice = relationship('Invoice', back_populates='items')
room = relationship('Room')
service = relationship('Service')

View File

@@ -0,0 +1,50 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Index
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ...shared.config.database import Base
class PaymentMethod(str, enum.Enum):
cash = 'cash'
credit_card = 'credit_card'
debit_card = 'debit_card'
bank_transfer = 'bank_transfer'
e_wallet = 'e_wallet'
stripe = 'stripe'
paypal = 'paypal'
borica = 'borica'
class PaymentType(str, enum.Enum):
full = 'full'
deposit = 'deposit'
remaining = 'remaining'
class PaymentStatus(str, enum.Enum):
pending = 'pending'
completed = 'completed'
failed = 'failed'
refunded = 'refunded'
class Payment(Base):
__tablename__ = 'payments'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True)
amount = Column(Numeric(10, 2), nullable=False)
payment_method = Column(Enum(PaymentMethod), nullable=False)
payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full)
deposit_percentage = Column(Integer, nullable=True)
related_payment_id = Column(Integer, ForeignKey('payments.id'), nullable=True)
payment_status = Column(Enum(PaymentStatus), nullable=False, default=PaymentStatus.pending)
transaction_id = Column(String(100), nullable=True)
payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
booking = relationship('Booking', back_populates='payments')
related_payment = relationship('Payment', remote_side=[id], backref='related_payments')
# Index for payment status queries
__table_args__ = (
Index('idx_payment_status', 'payment_status'),
Index('idx_payment_booking_status', 'booking_id', 'payment_status'),
)

View File

View File

@@ -0,0 +1,203 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..models.invoice import Invoice, InvoiceStatus
from ...bookings.models.booking import Booking
from ..services.invoice_service import InvoiceService
from ...shared.utils.role_helpers import can_access_all_invoices, can_create_invoices
from ...shared.utils.response_helpers import success_response
from ...shared.utils.request_helpers import get_request_id
from ..schemas.invoice import (
CreateInvoiceRequest,
UpdateInvoiceRequest,
MarkInvoicePaidRequest
)
logger = get_logger(__name__)
router = APIRouter(prefix='/invoices', tags=['invoices'])
@router.get('/')
async def get_invoices(request: Request, booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
user_id = None if can_access_all_invoices(current_user, db) else current_user.id
result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit)
return success_response(data=result)
except Exception as e:
db.rollback()
logger.error(f'Error fetching invoices: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/{id}')
async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
invoice = InvoiceService.get_invoice(id, db)
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
if not can_access_all_invoices(current_user, db) and invoice['user_id'] != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
return success_response(data={'invoice': invoice})
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f'Error fetching invoice by id: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post('/')
async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
if not can_create_invoices(current_user, db):
raise HTTPException(status_code=403, detail='Forbidden')
booking_id = invoice_data.booking_id
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
invoice_kwargs = {
'company_name': invoice_data.company_name,
'company_address': invoice_data.company_address,
'company_phone': invoice_data.company_phone,
'company_email': invoice_data.company_email,
'company_tax_id': invoice_data.company_tax_id,
'company_logo_url': invoice_data.company_logo_url,
'customer_tax_id': invoice_data.customer_tax_id,
'notes': invoice_data.notes,
'terms_and_conditions': invoice_data.terms_and_conditions,
'payment_instructions': invoice_data.payment_instructions
}
invoice_notes = invoice_kwargs.get('notes', '')
if booking.promotion_code:
promotion_note = f'Promotion Code: {booking.promotion_code}'
invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note
invoice_kwargs['notes'] = invoice_notes
request_id = get_request_id(request)
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking_id,
db=db,
created_by_id=current_user.id,
tax_rate=invoice_data.tax_rate,
discount_amount=invoice_data.discount_amount,
due_days=invoice_data.due_days,
request_id=request_id,
**invoice_kwargs
)
return success_response(data={'invoice': invoice}, message='Invoice created successfully')
except HTTPException:
raise
except ValueError as e:
db.rollback()
logger.error(f'Error creating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
db.rollback()
logger.error(f'Error creating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}')
async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
request_id = get_request_id(request)
invoice_dict = invoice_data.model_dump(exclude_unset=True)
updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, request_id=request_id, **invoice_dict)
return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully')
except HTTPException:
raise
except ValueError as e:
db.rollback()
logger.error(f'Error updating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
db.rollback()
logger.error(f'Error updating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post('/{id}/mark-paid')
async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try:
request_id = get_request_id(request)
amount = payment_data.amount
updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id, request_id=request_id)
return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully')
except HTTPException:
raise
except ValueError as e:
db.rollback()
logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
db.rollback()
logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}')
async def delete_invoice(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
db.delete(invoice)
db.commit()
return success_response(message='Invoice deleted successfully')
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get('/booking/{booking_id}')
async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
if not can_access_all_invoices(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
result = InvoiceService.get_invoices(db=db, booking_id=booking_id)
return success_response(data=result)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post('/{id}/send-email')
async def send_invoice_email(request: Request, id: int, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
from ..routes.booking_routes import _generate_invoice_email_html
from ...auth.models.user import User as UserModel
from ...shared.utils.mailer import send_email
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
user = db.query(UserModel).filter(UserModel.id == invoice.user_id).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
await send_email(
to=user.email,
subject=f'{invoice_type} {invoice.invoice_number}',
html=invoice_html
)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
return success_response(message=f'{invoice_type} sent successfully to {user.email}')
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f'Error sending invoice email: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,843 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
from sqlalchemy.orm import Session, joinedload, selectinload, load_only
from typing import Optional
from datetime import datetime
import os
from ...shared.config.database import get_db
from ...shared.config.settings import settings
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ...bookings.models.booking import Booking, BookingStatus
from ...shared.utils.role_helpers import can_access_all_payments
from ...shared.utils.currency_helpers import get_currency_symbol
from ...shared.utils.response_helpers import success_response
from ...shared.utils.mailer import send_email
from ...shared.utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
from ..services.stripe_service import StripeService
from ..services.paypal_service import PayPalService
from ..services.borica_service import BoricaService
from ...loyalty.services.loyalty_service import LoyaltyService
from ...analytics.services.audit_service import audit_service
from ..schemas.payment import CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest
logger = get_logger(__name__)
router = APIRouter(prefix='/payments', tags=['payments'])
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'):
if booking.status == BookingStatus.cancelled:
return
from sqlalchemy.orm import selectinload
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
booking = db.query(Booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
),
selectinload(Booking.payments)
).filter(Booking.id == booking.id).first()
if booking.payments:
for payment in booking.payments:
if payment.payment_status == PaymentStatus.pending:
payment.payment_status = PaymentStatus.failed
existing_notes = payment.notes or ''
cancellation_note = f'\nPayment cancelled due to booking cancellation: {reason} on {datetime.utcnow().isoformat()}'
payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
booking.status = BookingStatus.cancelled
db.commit()
db.refresh(booking)
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
if booking.user:
email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url)
await send_email(to=booking.user.email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to send cancellation email: {e}')
@router.get('/')
async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
if booking_id:
query = db.query(Payment).filter(Payment.booking_id == booking_id)
else:
query = db.query(Payment)
if status_filter:
try:
query = query.filter(Payment.payment_status == PaymentStatus(status_filter))
except ValueError:
pass
if not can_access_all_payments(current_user, db):
query = query.join(Booking).filter(Booking.user_id == current_user.id)
total = query.count()
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
query = query.options(
selectinload(Payment.booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
),
joinedload(Booking.user)
)
)
offset = (page - 1) * limit
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
result = []
for payment in payments:
payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None}
if payment.booking:
payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number}
if payment.booking.user:
payment_dict['booking']['user'] = {'id': payment.booking.user.id, 'name': payment.booking.user.full_name, 'full_name': payment.booking.user.full_name, 'email': payment.booking.user.email}
result.append(payment_dict)
return success_response(data={'payments': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}})
except HTTPException:
raise
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error fetching payments: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=f'Error fetching payments: {str(e)}')
@router.get('/booking/{booking_id}')
async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
from ...shared.utils.role_helpers import is_admin
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
if not is_admin(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
payments = db.query(Payment).options(
joinedload(Payment.booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
),
joinedload(Booking.user)
)
).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
result = []
for payment in payments:
payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None}
if payment.booking:
payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number}
if payment.booking.user:
payment_dict['booking']['user'] = {'id': payment.booking.user.id, 'name': payment.booking.user.full_name, 'full_name': payment.booking.user.full_name, 'email': payment.booking.user.email}
result.append(payment_dict)
return success_response(data={'payments': result})
except HTTPException:
raise
except Exception as e:
db.rollback()
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error in get_payments_by_booking_id: {str(e)}', extra={'booking_id': booking_id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching payments')
@router.get('/{id}')
async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
payment = db.query(Payment).filter(Payment.id == id).first()
if not payment:
raise HTTPException(status_code=404, detail='Payment not found')
if not can_access_all_payments(current_user, db):
if payment.booking and payment.booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None}
if payment.booking:
payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number}
return success_response(data={'payment': payment_dict})
except HTTPException:
raise
except Exception as e:
db.rollback()
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error in get_payment_by_id: {str(e)}', extra={'payment_id': id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching payment')
@router.post('/')
async def create_payment(
request: Request,
payment_data: CreatePaymentRequest,
current_user: User=Depends(get_current_user),
db: Session=Depends(get_db)
):
"""Create a payment with validated input using Pydantic schema."""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
booking_id = payment_data.booking_id
amount = payment_data.amount
payment_method = payment_data.payment_method
payment_type = payment_data.payment_type
mark_as_paid = payment_data.mark_as_paid
notes = payment_data.notes
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
from ...shared.utils.role_helpers import is_admin
if not is_admin(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if mark_as_paid else None, notes=notes)
if mark_as_paid:
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
db.add(payment)
db.commit()
db.refresh(payment)
# Send payment receipt notification
if payment.payment_status == PaymentStatus.completed:
try:
from ...notifications.services.notification_service import NotificationService
NotificationService.send_payment_receipt(db, payment)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment receipt notification: {e}')
# Award loyalty points if payment completed and booking is confirmed
if payment.payment_status == PaymentStatus.completed and booking:
try:
db.refresh(booking)
if booking.status == BookingStatus.confirmed and booking.user:
# Check if booking already earned points
from ...loyalty.models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource
existing_points = db.query(LoyaltyPointTransaction).filter(
LoyaltyPointTransaction.booking_id == booking.id,
LoyaltyPointTransaction.source == TransactionSource.booking
).first()
if not existing_points:
# Award points based on payment amount
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
if total_paid > 0:
LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid)
except Exception as loyalty_error:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to award loyalty points: {loyalty_error}')
if payment.payment_status == PaymentStatus.completed and booking.user:
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, payment_type=payment.payment_type.value if payment.payment_type else None, total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to send payment confirmation email: {e}')
# Log payment transaction
await audit_service.log_action(
db=db,
action='payment_created',
resource_type='payment',
user_id=current_user.id,
resource_id=payment.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={
'booking_id': booking_id,
'amount': float(amount),
'payment_method': payment_method,
'payment_type': payment_type,
'payment_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status),
'transaction_id': payment.transaction_id
},
status='success'
)
return success_response(data={'payment': payment}, message='Payment created successfully')
except HTTPException:
raise
except Exception as e:
db.rollback()
# Log failed payment creation
await audit_service.log_action(
db=db,
action='payment_creation_failed',
resource_type='payment',
user_id=current_user.id if current_user else None,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'booking_id': payment_data.booking_id, 'amount': payment_data.amount},
status='failed',
error_message=str(e)
)
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))])
async def update_payment_status(
request: Request,
id: int,
status_data: UpdatePaymentStatusRequest,
current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')),
db: Session=Depends(get_db)
):
"""Update payment status with validated input using Pydantic schema."""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try:
payment = db.query(Payment).filter(Payment.id == id).first()
if not payment:
raise HTTPException(status_code=404, detail='Payment not found')
status_value = status_data.status
notes = status_data.notes
old_status = payment.payment_status
if status_value:
try:
new_status = PaymentStatus(status_value)
payment.payment_status = new_status
# Only cancel booking if it's a full refund or all payments are failed
if new_status in [PaymentStatus.failed, PaymentStatus.refunded]:
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
booking = db.query(Booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
),
selectinload(Booking.payments)
).filter(Booking.id == payment.booking_id).first()
if booking and booking.status != BookingStatus.cancelled:
# Check if this is a full refund or if all payments are failed
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
total_price = float(booking.total_price) if booking.total_price else 0.0
all_payments_failed = all(p.payment_status in [PaymentStatus.failed, PaymentStatus.refunded] for p in booking.payments)
is_full_refund = new_status == PaymentStatus.refunded and float(payment.amount) >= total_price
# Only cancel if it's a full refund or all payments failed
if is_full_refund or (new_status == PaymentStatus.failed and all_payments_failed):
await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}')
except ValueError:
raise HTTPException(status_code=400, detail='Invalid payment status')
# Update notes if provided
if notes:
existing_notes = payment.notes or ''
payment.notes = f'{existing_notes}\n{notes}'.strip() if existing_notes else notes
db.commit()
db.refresh(payment)
# Log payment status update (admin action)
await audit_service.log_action(
db=db,
action='payment_status_updated',
resource_type='payment',
user_id=current_user.id,
resource_id=payment.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={
'payment_id': id,
'old_status': old_status.value if hasattr(old_status, 'value') else str(old_status),
'new_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status),
'booking_id': payment.booking_id,
'amount': float(payment.amount) if payment.amount else 0.0,
'notes': notes
},
status='success'
)
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
# Send payment receipt notification
try:
from ...notifications.services.notification_service import NotificationService
NotificationService.send_payment_receipt(db, payment)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment receipt notification: {e}')
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
payment = db.query(Payment).filter(Payment.id == id).first()
if payment.booking and payment.booking.user:
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
email_html = payment_confirmation_email_template(booking_number=payment.booking.booking_number, guest_name=payment.booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, client_url=client_url, currency_symbol=currency_symbol)
await send_email(to=payment.booking.user.email, subject=f'Payment Confirmed - {payment.booking.booking_number}', html=email_html)
if payment.payment_type == PaymentType.deposit and payment.booking:
payment.booking.deposit_paid = True
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
payment.booking.status = BookingStatus.confirmed
db.commit()
elif payment.payment_type == PaymentType.full and payment.booking:
total_paid = sum((float(p.amount) for p in payment.booking.payments if p.payment_status == PaymentStatus.completed))
if total_paid >= float(payment.booking.total_price):
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
payment.booking.status = BookingStatus.confirmed
db.commit()
except Exception as e:
logger.error(f'Failed to send payment confirmation email: {str(e)}', exc_info=True, extra={'payment_id': payment.id if hasattr(payment, 'id') else None})
return success_response(data={'payment': payment}, message='Payment status updated successfully')
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/stripe/create-intent')
async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
from ..services.stripe_service import get_stripe_secret_key
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.')
booking_id = intent_data.booking_id
amount = intent_data.amount
currency = intent_data.currency or 'usd'
import logging
logger = logging.getLogger(__name__)
logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}')
if amount > 999999.99:
logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99")
raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.")
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
from ...shared.utils.role_helpers import is_admin
if not is_admin(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
if booking.requires_deposit and (not booking.deposit_paid):
deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if deposit_payment:
expected_deposit_amount = float(deposit_payment.amount)
if abs(amount - expected_deposit_amount) > 0.01:
logger.warning(f'Amount mismatch for deposit payment: Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, Booking total ${float(booking.total_price):,.2f}')
raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f}).')
intent = StripeService.create_payment_intent(amount=amount, currency=currency, metadata={'booking_id': str(booking_id), 'booking_number': booking.booking_number, 'user_id': str(current_user.id)}, db=db)
from ..services.stripe_service import get_stripe_publishable_key
publishable_key = get_stripe_publishable_key(db)
if not publishable_key:
publishable_key = settings.STRIPE_PUBLISHABLE_KEY
if not publishable_key:
import logging
logger = logging.getLogger(__name__)
logger.warning('Stripe publishable key is not configured')
raise HTTPException(status_code=500, detail='Stripe publishable key is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_PUBLISHABLE_KEY environment variable.')
if not intent.get('client_secret'):
import logging
logger = logging.getLogger(__name__)
logger.error('Payment intent created but client_secret is missing')
raise HTTPException(status_code=500, detail='Failed to create payment intent. Client secret is missing.')
return success_response(data={'client_secret': intent['client_secret'], 'payment_intent_id': intent['id'], 'publishable_key': publishable_key}, message='Payment intent created successfully')
except HTTPException:
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Payment intent creation error: {str(e)}')
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error creating payment intent: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post('/stripe/confirm')
async def confirm_stripe_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
payment_intent_id = payment_data.get('payment_intent_id')
booking_id = payment_data.get('booking_id')
if not payment_intent_id:
raise HTTPException(status_code=400, detail='payment_intent_id is required')
payment = await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=booking_id)
try:
db.commit()
except Exception:
pass
booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first()
if booking:
db.refresh(booking)
if booking and booking.user:
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='stripe', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment confirmation email: {e}')
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
except HTTPException:
db.rollback()
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Payment confirmation error: {str(e)}')
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error confirming payment: {str(e)}', exc_info=True)
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/stripe/webhook')
async def stripe_webhook(request: Request, db: Session=Depends(get_db)):
try:
from ..services.stripe_service import get_stripe_webhook_secret
webhook_secret = get_stripe_webhook_secret(db)
if not webhook_secret:
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
raise HTTPException(status_code=503, detail={'status': 'error', 'message': 'Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.'})
payload = await request.body()
signature = request.headers.get('stripe-signature')
if not signature:
raise HTTPException(status_code=400, detail='Missing stripe-signature header')
result = await StripeService.handle_webhook(payload=payload, signature=signature, db=db)
return success_response(data=result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/paypal/create-order')
async def create_paypal_order(order_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
from ..services.paypal_service import get_paypal_client_id, get_paypal_client_secret
client_id = get_paypal_client_id(db)
if not client_id:
client_id = settings.PAYPAL_CLIENT_ID
client_secret = get_paypal_client_secret(db)
if not client_secret:
client_secret = settings.PAYPAL_CLIENT_SECRET
if not client_id or not client_secret:
raise HTTPException(status_code=500, detail='PayPal is not configured. Please configure PayPal settings in Admin Panel or set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables.')
booking_id = order_data.get('booking_id')
amount = float(order_data.get('amount', 0))
currency = order_data.get('currency', 'USD')
if not booking_id or amount <= 0:
raise HTTPException(status_code=400, detail='booking_id and amount are required')
if amount > 100000:
raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000. Please contact support for large payments.")
from ...shared.utils.role_helpers import is_admin
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
if not is_admin(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
if booking.requires_deposit and (not booking.deposit_paid):
deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if deposit_payment:
expected_deposit_amount = float(deposit_payment.amount)
if abs(amount - expected_deposit_amount) > 0.01:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Amount mismatch for deposit payment: Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, Booking total ${float(booking.total_price):,.2f}')
raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f}).')
client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
return_url = order_data.get('return_url', f'{client_url}/payment/paypal/return')
cancel_url = order_data.get('cancel_url', f'{client_url}/payment/paypal/cancel')
order = PayPalService.create_order(amount=amount, currency=currency, metadata={'booking_id': str(booking_id), 'booking_number': booking.booking_number, 'user_id': str(current_user.id), 'description': f'Hotel Booking Payment - {booking.booking_number}', 'return_url': return_url, 'cancel_url': cancel_url}, db=db)
if not order.get('approval_url'):
raise HTTPException(status_code=500, detail='Failed to create PayPal order. Approval URL is missing.')
return success_response(data={'order_id': order['id'], 'approval_url': order['approval_url'], 'status': order['status']}, message='PayPal order created successfully')
except HTTPException:
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'PayPal order creation error: {str(e)}')
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error creating PayPal order: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post('/paypal/cancel')
async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
booking_id = payment_data.get('booking_id')
if not booking_id:
raise HTTPException(status_code=400, detail='booking_id is required')
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_method == PaymentMethod.paypal, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if not payment:
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if payment:
payment.payment_status = PaymentStatus.failed
db.commit()
db.refresh(payment)
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if booking and booking.status != BookingStatus.cancelled:
await cancel_booking_on_payment_failure(booking, db, reason='PayPal payment canceled by user')
return success_response(message='Payment canceled and booking cancelled')
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/paypal/capture')
async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
order_id = payment_data.get('order_id')
booking_id = payment_data.get('booking_id')
if not order_id:
raise HTTPException(status_code=400, detail='order_id is required')
payment = await PayPalService.confirm_payment(order_id=order_id, db=db, booking_id=booking_id)
try:
db.commit()
except Exception:
pass
booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first()
if booking:
db.refresh(booking)
if booking and booking.user:
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='paypal', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment confirmation email: {e}')
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
except HTTPException:
db.rollback()
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'PayPal payment confirmation error: {str(e)}')
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error confirming PayPal payment: {str(e)}', exc_info=True)
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/borica/create-payment')
async def create_borica_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
from ..services.borica_service import get_borica_terminal_id, get_borica_merchant_id
terminal_id = get_borica_terminal_id(db)
merchant_id = get_borica_merchant_id(db)
if not terminal_id or not merchant_id:
if not settings.BORICA_TERMINAL_ID or not settings.BORICA_MERCHANT_ID:
raise HTTPException(status_code=500, detail='Borica is not configured. Please configure Borica settings in Admin Panel or set BORICA_TERMINAL_ID and BORICA_MERCHANT_ID environment variables.')
booking_id = payment_data.get('booking_id')
amount = float(payment_data.get('amount', 0))
currency = payment_data.get('currency', 'BGN')
if not booking_id or amount <= 0:
raise HTTPException(status_code=400, detail='booking_id and amount are required')
if amount > 100000:
raise HTTPException(status_code=400, detail=f"Amount {amount:,.2f} exceeds maximum of 100,000. Please contact support for large payments.")
from ...shared.utils.role_helpers import is_admin
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
if not is_admin(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
if booking.requires_deposit and (not booking.deposit_paid):
deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if deposit_payment:
expected_deposit_amount = float(deposit_payment.amount)
if abs(amount - expected_deposit_amount) > 0.01:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Amount mismatch for deposit payment: Requested {amount:,.2f}, Expected deposit {expected_deposit_amount:,.2f}, Booking total {float(booking.total_price):,.2f}')
raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount ({expected_deposit_amount:,.2f}) should be charged, not the full booking amount ({float(booking.total_price):,.2f}).')
transaction_id = BoricaService.generate_transaction_id(booking_id)
client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
return_url = payment_data.get('return_url', f'{client_url}/payment/borica/return')
description = f'Hotel Booking Payment - {booking.booking_number}'
payment_request = BoricaService.create_payment_request(amount=amount, currency=currency, order_id=transaction_id, description=description, return_url=return_url, db=db)
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.pending, transaction_id=transaction_id, notes=f'Borica payment initiated - Order: {transaction_id}')
db.add(payment)
db.commit()
db.refresh(payment)
return success_response(data={'payment_request': payment_request, 'payment_id': payment.id, 'transaction_id': transaction_id}, message='Borica payment request created successfully')
except HTTPException:
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Borica payment creation error: {str(e)}')
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error creating Borica payment: {str(e)}', exc_info=True)
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/borica/callback')
async def borica_callback(request: Request, db: Session=Depends(get_db)):
"""
Handle Borica payment callback (POST from Borica gateway).
Borica sends POST data with payment response.
"""
try:
form_data = await request.form()
response_data = dict(form_data)
# Also try to get from JSON if available
try:
json_data = await request.json()
response_data.update(json_data)
except:
pass
payment = await BoricaService.confirm_payment(response_data=response_data, db=db)
try:
db.commit()
except Exception:
pass
booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first()
if booking:
db.refresh(booking)
if booking and booking.user:
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='borica', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment confirmation email: {e}')
# Redirect to return URL with success status
return_url = response_data.get('BACKREF', '')
if return_url:
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"{return_url}?status=success&order={response_data.get('ORDER', '')}&bookingId={payment['booking_id']}")
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
except HTTPException:
db.rollback()
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Borica payment callback error: {str(e)}')
db.rollback()
# Redirect to return URL with error status
return_url = dict(await request.form()).get('BACKREF', '') if hasattr(request, 'form') else ''
if return_url:
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"{return_url}?status=error&error={str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error in Borica callback: {str(e)}', exc_info=True)
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post('/borica/confirm')
async def confirm_borica_payment(response_data: dict, db: Session=Depends(get_db)):
try:
payment = await BoricaService.confirm_payment(response_data=response_data, db=db)
try:
db.commit()
except Exception:
pass
booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first()
if booking:
db.refresh(booking)
if booking and booking.user:
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbol = get_currency_symbol(currency)
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='borica', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment confirmation email: {e}')
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
except HTTPException:
db.rollback()
raise
except ValueError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Borica payment confirmation error: {str(e)}')
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Unexpected error confirming Borica payment: {str(e)}', exc_info=True)
db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

View File

@@ -0,0 +1,77 @@
"""
Pydantic schemas for invoice-related requests and responses.
"""
from pydantic import BaseModel, Field
from typing import Optional
class CreateInvoiceRequest(BaseModel):
"""Schema for creating an invoice."""
booking_id: int = Field(..., gt=0, description="Booking ID")
tax_rate: float = Field(0.0, ge=0, le=100, description="Tax rate percentage")
discount_amount: float = Field(0.0, ge=0, description="Discount amount")
due_days: int = Field(30, ge=1, le=365, description="Number of days until due")
company_name: Optional[str] = Field(None, max_length=200)
company_address: Optional[str] = Field(None, max_length=500)
company_phone: Optional[str] = Field(None, max_length=50)
company_email: Optional[str] = Field(None, max_length=255)
company_tax_id: Optional[str] = Field(None, max_length=50)
company_logo_url: Optional[str] = Field(None, max_length=500)
customer_tax_id: Optional[str] = Field(None, max_length=50)
notes: Optional[str] = Field(None, max_length=1000)
terms_and_conditions: Optional[str] = None
payment_instructions: Optional[str] = None
model_config = {
"json_schema_extra": {
"example": {
"booking_id": 1,
"tax_rate": 10.0,
"discount_amount": 0.0,
"due_days": 30,
"company_name": "Hotel Name",
"company_address": "123 Main St",
"notes": "Payment due within 30 days"
}
}
}
class UpdateInvoiceRequest(BaseModel):
"""Schema for updating an invoice."""
company_name: Optional[str] = Field(None, max_length=200)
company_address: Optional[str] = Field(None, max_length=500)
company_phone: Optional[str] = Field(None, max_length=50)
company_email: Optional[str] = Field(None, max_length=255)
company_tax_id: Optional[str] = Field(None, max_length=50)
company_logo_url: Optional[str] = Field(None, max_length=500)
customer_tax_id: Optional[str] = Field(None, max_length=50)
notes: Optional[str] = Field(None, max_length=1000)
terms_and_conditions: Optional[str] = None
payment_instructions: Optional[str] = None
tax_rate: Optional[float] = Field(None, ge=0, le=100)
discount_amount: Optional[float] = Field(None, ge=0)
status: Optional[str] = None
model_config = {
"json_schema_extra": {
"example": {
"notes": "Updated notes",
"status": "paid"
}
}
}
class MarkInvoicePaidRequest(BaseModel):
"""Schema for marking an invoice as paid."""
amount: Optional[float] = Field(None, gt=0, description="Payment amount (optional, defaults to full amount)")
model_config = {
"json_schema_extra": {
"example": {
"amount": 500.00
}
}
}

View File

@@ -0,0 +1,55 @@
"""
Pydantic schemas for payment-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional
class CreatePaymentRequest(BaseModel):
"""Schema for creating a payment."""
booking_id: int = Field(..., gt=0, description="Booking ID")
amount: float = Field(..., gt=0, le=999999.99, description="Payment amount")
payment_method: str = Field(..., description="Payment method")
payment_type: str = Field("full", description="Payment type (full, deposit)")
mark_as_paid: Optional[bool] = Field(False, description="Mark payment as completed immediately")
notes: Optional[str] = Field(None, max_length=1000, description="Payment notes")
@field_validator('payment_method')
@classmethod
def validate_payment_method(cls, v: str) -> str:
"""Validate payment method."""
allowed_methods = ['cash', 'stripe', 'paypal', 'borica']
if v not in allowed_methods:
raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}')
return v
class UpdatePaymentStatusRequest(BaseModel):
"""Schema for updating payment status."""
status: str = Field(..., description="New payment status")
notes: Optional[str] = Field(None, max_length=1000, description="Status change notes")
@field_validator('status')
@classmethod
def validate_status(cls, v: str) -> str:
"""Validate payment status."""
allowed_statuses = ['pending', 'completed', 'failed', 'refunded', 'cancelled']
if v not in allowed_statuses:
raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}')
return v
class CreateStripePaymentIntentRequest(BaseModel):
"""Schema for creating a Stripe payment intent."""
booking_id: int = Field(..., gt=0, description="Booking ID")
amount: float = Field(..., gt=0, le=999999.99, description="Payment amount")
currency: Optional[str] = Field("usd", description="Currency code")
@field_validator('amount')
@classmethod
def validate_amount(cls, v: float) -> float:
"""Validate amount doesn't exceed Stripe limit."""
if v > 999999.99:
raise ValueError(f"Amount ${v:,.2f} exceeds Stripe's maximum of $999,999.99")
return v

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

View File

@@ -0,0 +1,233 @@
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus
from ...bookings.models.booking import Booking
from ..models.payment import Payment, PaymentStatus
from ...auth.models.user import User
from ...system.models.system_settings import SystemSettings
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
def generate_invoice_number(db: Session, is_proforma: bool=False) -> str:
prefix = 'PRO' if is_proforma else 'INV'
today = datetime.utcnow().strftime('%Y%m%d')
last_invoice = db.query(Invoice).filter(Invoice.invoice_number.like(f'{prefix}-{today}-%')).order_by(Invoice.invoice_number.desc()).first()
if last_invoice:
try:
sequence = int(last_invoice.invoice_number.split('-')[-1])
sequence += 1
except (ValueError, IndexError):
sequence = 1
else:
sequence = 1
return f'{prefix}-{today}-{sequence:04d}'
class InvoiceService:
@staticmethod
def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
from sqlalchemy.orm import selectinload
from ...hotel_services.models.service_usage import ServiceUsage
from ...rooms.models.room import Room
from ...rooms.models.room_type import RoomType
from ...hotel_services.models.service import Service
logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id})
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload(ServiceUsage.service),
selectinload(Booking.room).selectinload(Room.room_type),
selectinload(Booking.payments)
).filter(Booking.id == booking_id).first()
if not booking:
logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id})
raise ValueError('Booking not found')
user = db.query(User).filter(User.id == booking.user_id).first()
if not user:
raise ValueError('User not found')
# Get tax_rate from system settings if not provided or is 0
if tax_rate == 0.0:
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first()
if tax_rate_setting and tax_rate_setting.value:
try:
tax_rate = float(tax_rate_setting.value)
except (ValueError, TypeError):
tax_rate = 0.0
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
booking_total = float(booking.total_price)
if invoice_amount is not None:
subtotal = float(invoice_amount)
if invoice_amount < booking_total and discount_amount > 0:
if discount_amount > subtotal * 0.5:
proportion = float(invoice_amount) / booking_total
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
discount_amount = original_discount * proportion
else:
subtotal = booking_total
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
amount_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
balance_due = total_amount - amount_paid
if balance_due <= 0:
status = InvoiceStatus.paid
paid_date = datetime.utcnow()
elif amount_paid > 0:
status = InvoiceStatus.sent
paid_date = None
else:
status = InvoiceStatus.draft
paid_date = None
# Get company information from system settings if not provided
company_name = kwargs.get('company_name')
company_address = kwargs.get('company_address')
company_phone = kwargs.get('company_phone')
company_email = kwargs.get('company_email')
company_tax_id = kwargs.get('company_tax_id')
company_logo_url = kwargs.get('company_logo_url')
# If company info not provided, fetch from system settings
if not company_name or not company_address or not company_phone or not company_email:
company_settings = db.query(SystemSettings).filter(
SystemSettings.key.in_(['company_name', 'company_address', 'company_phone', 'company_email', 'company_logo_url'])
).all()
settings_dict = {setting.key: setting.value for setting in company_settings if setting.value}
if not company_name and settings_dict.get('company_name'):
company_name = settings_dict['company_name']
if not company_address and settings_dict.get('company_address'):
company_address = settings_dict['company_address']
if not company_phone and settings_dict.get('company_phone'):
company_phone = settings_dict['company_phone']
if not company_email and settings_dict.get('company_email'):
company_email = settings_dict['company_email']
if not company_logo_url and settings_dict.get('company_logo_url'):
company_logo_url = settings_dict['company_logo_url']
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=company_name, company_address=company_address, company_phone=company_phone, company_email=company_email, company_tax_id=company_tax_id, company_logo_url=company_logo_url, customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
db.add(invoice)
db.flush()
services_total = sum((float(su.total_price) for su in booking.service_usages))
booking_total = float(booking.total_price)
room_price = booking_total - services_total
nights = (booking.check_out_date - booking.check_in_date).days
if nights <= 0:
nights = 1
if invoice_amount is not None and invoice_amount < booking_total:
proportion = float(invoice_amount) / booking_total
room_price = room_price * proportion
services_total = services_total * proportion
item_description_suffix = f' (Partial: {proportion * 100:.0f}%)'
else:
item_description_suffix = ''
room_item = InvoiceItem(invoice_id=invoice.id, description=f'Room: {booking.room.room_number} - {(booking.room.room_type.name if booking.room.room_type else 'N/A')} ({nights} night{('s' if nights > 1 else '')}){item_description_suffix}', quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, line_total=room_price, room_id=booking.room_id)
db.add(room_item)
for service_usage in booking.service_usages:
service_item_price = float(service_usage.total_price)
if invoice_amount is not None and invoice_amount < booking_total:
proportion = float(invoice_amount) / booking_total
service_item_price = service_item_price * proportion
service_item = InvoiceItem(invoice_id=invoice.id, description=f'Service: {service_usage.service.name}{item_description_suffix}', quantity=float(service_usage.quantity), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, line_total=service_item_price, service_id=service_usage.service_id)
db.add(service_item)
subtotal = room_price + services_total
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
balance_due = total_amount - amount_paid
invoice.subtotal = subtotal
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.balance_due = balance_due
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise ValueError('Invoice not found')
allowed_fields = ['company_name', 'company_address', 'company_phone', 'company_email', 'company_tax_id', 'company_logo_url', 'notes', 'terms_and_conditions', 'payment_instructions', 'status', 'due_date', 'tax_rate', 'discount_amount']
for field in allowed_fields:
if field in kwargs:
setattr(invoice, field, kwargs[field])
if 'tax_rate' in kwargs or 'discount_amount' in kwargs:
tax_rate = kwargs.get('tax_rate', invoice.tax_rate)
discount_amount = kwargs.get('discount_amount', invoice.discount_amount)
# Convert decimal types to float for arithmetic operations
subtotal = float(invoice.subtotal) if invoice.subtotal else 0.0
discount_amount = float(discount_amount) if discount_amount else 0.0
tax_rate = float(tax_rate) if tax_rate else 0.0
amount_paid = float(invoice.amount_paid) if invoice.amount_paid else 0.0
invoice.tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
invoice.total_amount = subtotal + invoice.tax_amount - discount_amount
invoice.balance_due = invoice.total_amount - amount_paid
if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.balance_due > 0 and invoice.status == InvoiceStatus.paid:
invoice.status = InvoiceStatus.sent
invoice.paid_date = None
invoice.updated_by_id = updated_by_id
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None, request_id: Optional[str]=None) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise ValueError('Invoice not found')
payment_amount = amount if amount is not None else float(invoice.balance_due)
invoice.amount_paid += payment_amount
invoice.balance_due = invoice.total_amount - invoice.amount_paid
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
else:
invoice.status = InvoiceStatus.sent
invoice.updated_by_id = updated_by_id
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def get_invoice(invoice_id: int, db: Session) -> Optional[Dict[str, Any]]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
return None
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def get_invoices(db: Session, user_id: Optional[int]=None, booking_id: Optional[int]=None, status: Optional[str]=None, page: int=1, limit: int=10) -> Dict[str, Any]:
query = db.query(Invoice)
if user_id:
query = query.filter(Invoice.user_id == user_id)
if booking_id:
query = query.filter(Invoice.booking_id == booking_id)
if status:
try:
status_enum = InvoiceStatus(status)
query = query.filter(Invoice.status == status_enum)
except ValueError:
pass
total = query.count()
offset = (page - 1) * limit
invoices = query.order_by(Invoice.created_at.desc()).offset(offset).limit(limit).all()
return {'invoices': [InvoiceService.invoice_to_dict(inv) for inv in invoices], 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit}
@staticmethod
def invoice_to_dict(invoice: Invoice) -> Dict[str, Any]:
promotion_code = None
if invoice.notes and 'Promotion Code:' in invoice.notes:
try:
promotion_code = invoice.notes.split('Promotion Code:')[1].split('\n')[0].strip()
except:
pass
return {'id': invoice.id, 'invoice_number': invoice.invoice_number, 'booking_id': invoice.booking_id, 'user_id': invoice.user_id, 'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None, 'due_date': invoice.due_date.isoformat() if invoice.due_date else None, 'paid_date': invoice.paid_date.isoformat() if invoice.paid_date else None, 'subtotal': float(invoice.subtotal) if invoice.subtotal else 0.0, 'tax_rate': float(invoice.tax_rate) if invoice.tax_rate else 0.0, 'tax_amount': float(invoice.tax_amount) if invoice.tax_amount else 0.0, 'discount_amount': float(invoice.discount_amount) if invoice.discount_amount else 0.0, 'total_amount': float(invoice.total_amount) if invoice.total_amount else 0.0, 'amount_paid': float(invoice.amount_paid) if invoice.amount_paid else 0.0, 'balance_due': float(invoice.balance_due) if invoice.balance_due else 0.0, 'status': invoice.status.value if invoice.status else None, 'company_name': invoice.company_name, 'company_address': invoice.company_address, 'company_phone': invoice.company_phone, 'company_email': invoice.company_email, 'company_tax_id': invoice.company_tax_id, 'company_logo_url': invoice.company_logo_url, 'customer_name': invoice.customer_name, 'customer_email': invoice.customer_email, 'customer_address': invoice.customer_address, 'customer_phone': invoice.customer_phone, 'customer_tax_id': invoice.customer_tax_id, 'notes': invoice.notes, 'terms_and_conditions': invoice.terms_and_conditions, 'payment_instructions': invoice.payment_instructions, 'is_proforma': invoice.is_proforma if hasattr(invoice, 'is_proforma') else False, 'promotion_code': promotion_code, 'items': [{'id': item.id, 'description': item.description, 'quantity': float(item.quantity) if item.quantity else 0.0, 'unit_price': float(item.unit_price) if item.unit_price else 0.0, 'tax_rate': float(item.tax_rate) if item.tax_rate else 0.0, 'discount_amount': float(item.discount_amount) if item.discount_amount else 0.0, 'line_total': float(item.line_total) if item.line_total else 0.0, 'room_id': item.room_id, 'service_id': item.service_id} for item in invoice.items], 'created_at': invoice.created_at.isoformat() if invoice.created_at else None, 'updated_at': invoice.updated_at.isoformat() if invoice.updated_at else None}

View File

@@ -0,0 +1,292 @@
import logging
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
from paypalcheckoutsdk.payments import CapturesRefundRequest
from typing import Optional, Dict, Any
from ...shared.config.settings import settings
from ...shared.config.logging_config import get_logger
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
from datetime import datetime
import json
logger = get_logger(__name__)
def get_paypal_client_id(db: Session) -> Optional[str]:
try:
setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_client_id').first()
if setting and setting.value:
return setting.value
except Exception:
pass
return settings.PAYPAL_CLIENT_ID if settings.PAYPAL_CLIENT_ID else None
def get_paypal_client_secret(db: Session) -> Optional[str]:
try:
setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_client_secret').first()
if setting and setting.value:
return setting.value
except Exception:
pass
return settings.PAYPAL_CLIENT_SECRET if settings.PAYPAL_CLIENT_SECRET else None
def get_paypal_mode(db: Session) -> str:
try:
setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_mode').first()
if setting and setting.value:
return setting.value
except Exception:
pass
return settings.PAYPAL_MODE if settings.PAYPAL_MODE else 'sandbox'
def get_paypal_client(db: Optional[Session]=None) -> PayPalHttpClient:
client_id = None
client_secret = None
mode = 'sandbox'
if db:
client_id = get_paypal_client_id(db)
client_secret = get_paypal_client_secret(db)
mode = get_paypal_mode(db)
if not client_id:
client_id = settings.PAYPAL_CLIENT_ID
if not client_secret:
client_secret = settings.PAYPAL_CLIENT_SECRET
if not mode:
mode = settings.PAYPAL_MODE or 'sandbox'
if not client_id or not client_secret:
raise ValueError('PayPal credentials are not configured')
if mode.lower() == 'live':
environment = LiveEnvironment(client_id=client_id, client_secret=client_secret)
else:
environment = SandboxEnvironment(client_id=client_id, client_secret=client_secret)
return PayPalHttpClient(environment)
class PayPalService:
@staticmethod
def create_order(amount: float, currency: str='USD', metadata: Optional[Dict[str, Any]]=None, db: Optional[Session]=None) -> Dict[str, Any]:
client = get_paypal_client(db)
if amount <= 0:
raise ValueError('Amount must be greater than 0')
if amount > 100000:
raise ValueError(f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000")
request = OrdersCreateRequest()
request.prefer('return=representation')
order_data = {'intent': 'CAPTURE', 'purchase_units': [{'amount': {'currency_code': currency.upper(), 'value': f'{amount:.2f}'}, 'description': metadata.get('description', 'Hotel Booking Payment') if metadata else 'Hotel Booking Payment', 'custom_id': metadata.get('booking_id') if metadata else None}], 'application_context': {'brand_name': 'Hotel Booking', 'landing_page': 'BILLING', 'user_action': 'PAY_NOW', 'return_url': metadata.get('return_url') if metadata else None, 'cancel_url': metadata.get('cancel_url') if metadata else None}}
if metadata:
order_data['purchase_units'][0]['invoice_id'] = metadata.get('booking_number')
request.request_body(order_data)
try:
response = client.execute(request)
order = response.result
approval_url = None
for link in order.links:
if link.rel == 'approve':
approval_url = link.href
break
return {'id': order.id, 'status': order.status, 'approval_url': approval_url, 'amount': amount, 'currency': currency.upper()}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
elif hasattr(e, 'details') and e.details:
error_msg = json.dumps(e.details)
raise ValueError(f'PayPal error: {error_msg}')
@staticmethod
def get_order(order_id: str, db: Optional[Session]=None) -> Dict[str, Any]:
client = get_paypal_client(db)
request = OrdersGetRequest(order_id)
try:
response = client.execute(request)
order = response.result
amount = 0.0
currency = 'USD'
if order.purchase_units and len(order.purchase_units) > 0:
amount_str = order.purchase_units[0].amount.value
currency = order.purchase_units[0].amount.currency_code
amount = float(amount_str)
return {'id': order.id, 'status': order.status, 'amount': amount, 'currency': currency, 'create_time': order.create_time, 'update_time': order.update_time}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
raise ValueError(f'PayPal error: {error_msg}')
@staticmethod
def capture_order(order_id: str, db: Optional[Session]=None) -> Dict[str, Any]:
client = get_paypal_client(db)
request = OrdersCaptureRequest(order_id)
request.prefer('return=representation')
try:
response = client.execute(request)
order = response.result
capture_id = None
amount = 0.0
currency = 'USD'
status = order.status
if order.purchase_units and len(order.purchase_units) > 0:
payments = order.purchase_units[0].payments
if payments and payments.captures and (len(payments.captures) > 0):
capture = payments.captures[0]
capture_id = capture.id
amount_str = capture.amount.value
currency = capture.amount.currency_code
amount = float(amount_str)
status = capture.status
return {'order_id': order.id, 'capture_id': capture_id, 'status': status, 'amount': amount, 'currency': currency}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
raise ValueError(f'PayPal error: {error_msg}')
@staticmethod
async def confirm_payment(order_id: str, db: Session, booking_id: Optional[int]=None) -> Dict[str, Any]:
try:
capture_data = PayPalService.capture_order(order_id, db)
if not booking_id:
order_data = PayPalService.get_order(order_id, db)
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')
capture_status = capture_data.get('status')
if capture_status not in ['COMPLETED', 'PENDING']:
raise ValueError(f'Payment capture not in a valid state. Status: {capture_status}')
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == order_id, Payment.payment_method == PaymentMethod.paypal).first()
if not payment:
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_method == PaymentMethod.paypal, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if not payment:
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
amount = capture_data['amount']
capture_id = capture_data.get('capture_id')
if payment:
if capture_status == 'COMPLETED':
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
payment.amount = amount
payment.payment_method = PaymentMethod.paypal
if capture_id:
payment.transaction_id = f'{order_id}|{capture_id}'
else:
payment_type = PaymentType.full
if booking.requires_deposit and (not booking.deposit_paid):
payment_type = PaymentType.deposit
payment_status_enum = PaymentStatus.completed if capture_status == 'COMPLETED' else PaymentStatus.pending
payment_date = datetime.utcnow() if capture_status == 'COMPLETED' else None
transaction_id = f'{order_id}|{capture_id}' if capture_id else order_id
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.paypal, payment_type=payment_type, payment_status=payment_status_enum, transaction_id=transaction_id, payment_date=payment_date, notes=f'PayPal payment - Order: {order_id}, Capture: {capture_id} (Status: {capture_status})')
db.add(payment)
db.commit()
db.refresh(payment)
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))
from ..models.invoice import Invoice, InvoiceStatus
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
for invoice in invoices:
invoice.amount_paid = total_paid
invoice.balance_due = float(invoice.total_amount) - total_paid
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.amount_paid > 0:
invoice.status = InvoiceStatus.sent
booking_was_confirmed = False
should_send_email = False
if payment.payment_type == PaymentType.deposit:
booking.deposit_paid = True
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
should_send_email = True
elif payment.payment_type == PaymentType.full:
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
should_send_email = True
if should_send_email:
try:
from ...shared.utils.mailer import send_email
from ...shared.utils.email_templates import booking_confirmation_email_template
from ...system.models.system_settings import SystemSettings
from ...rooms.models.room import Room
from sqlalchemy.orm import selectinload
import os
from ...shared.config.settings import settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbols = {'USD': '$', 'EUR': '', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '', 'INR': '', 'CHF': 'CHF', 'NZD': 'NZ$'}
currency_symbol = currency_symbols.get(currency, currency)
booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking_id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else 'Room'
amount_paid = total_paid
payment_type_str = payment.payment_type.value if payment.payment_type else None
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=False, deposit_amount=None, amount_paid=amount_paid, payment_type=payment_type_str, client_url=client_url, currency_symbol=currency_symbol)
if booking.user:
await send_email(to=booking.user.email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
logger.info(f'Booking confirmation email sent to {booking.user.email}')
except Exception as email_error:
logger.error(f'Failed to send booking confirmation email: {str(email_error)}')
from ...shared.utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
from ...auth.models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
if user:
await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
except Exception as email_error:
logger.error(f'Failed to send invoice email: {str(email_error)}')
from ...shared.utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..models.invoice import InvoiceStatus
from ..routes.booking_routes import _generate_invoice_email_html
from ...auth.models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
if user:
await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
except Exception as email_error:
logger.error(f'Failed to send invoice email: {str(email_error)}')
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:
error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}'
logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'order_id': order_id, 'booking_id': booking_id})
db.rollback()
raise ValueError(f'Error confirming payment: {error_msg}')

View File

@@ -0,0 +1,283 @@
import logging
import stripe
from typing import Optional, Dict, Any
from ...shared.config.settings import settings
from ...shared.config.logging_config import get_logger
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
from datetime import datetime
logger = get_logger(__name__)
def get_stripe_secret_key(db: Session) -> Optional[str]:
try:
setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_secret_key').first()
if setting and setting.value:
return setting.value
except Exception:
pass
return settings.STRIPE_SECRET_KEY if settings.STRIPE_SECRET_KEY else None
def get_stripe_publishable_key(db: Session) -> Optional[str]:
try:
setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_publishable_key').first()
if setting and setting.value:
return setting.value
except Exception:
pass
return settings.STRIPE_PUBLISHABLE_KEY if settings.STRIPE_PUBLISHABLE_KEY else None
def get_stripe_webhook_secret(db: Session) -> Optional[str]:
try:
setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_webhook_secret').first()
if setting and setting.value:
return setting.value
except Exception:
pass
return settings.STRIPE_WEBHOOK_SECRET if settings.STRIPE_WEBHOOK_SECRET else None
class StripeService:
@staticmethod
def create_payment_intent(amount: float, currency: str='usd', metadata: Optional[Dict[str, Any]]=None, customer_id: Optional[str]=None, db: Optional[Session]=None) -> Dict[str, Any]:
secret_key = None
if db:
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise ValueError('Stripe secret key is not configured')
stripe.api_key = secret_key
if amount <= 0:
raise ValueError('Amount must be greater than 0')
if amount > 999999.99:
raise ValueError(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99")
amount_in_cents = int(round(amount * 100))
if amount_in_cents > 99999999:
raise ValueError(f"Amount ${amount:,.2f} (${amount_in_cents} cents) exceeds Stripe's maximum")
intent_params = {'amount': amount_in_cents, 'currency': currency, 'automatic_payment_methods': {'enabled': True}, 'metadata': metadata or {}}
if customer_id:
intent_params['customer'] = customer_id
try:
intent = stripe.PaymentIntent.create(**intent_params)
return {'client_secret': intent.client_secret, 'id': intent.id, 'status': intent.status, 'amount': intent.amount, 'currency': intent.currency}
except stripe.StripeError as e:
raise ValueError(f'Stripe error: {str(e)}')
@staticmethod
def retrieve_payment_intent(payment_intent_id: str, db: Optional[Session]=None) -> Dict[str, Any]:
secret_key = None
if db:
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise ValueError('Stripe secret key is not configured')
stripe.api_key = secret_key
try:
intent = stripe.PaymentIntent.retrieve(payment_intent_id)
charges = []
if hasattr(intent, 'charges') and intent.charges:
charges_data = getattr(intent.charges, 'data', [])
charges = [{'id': charge.id, 'paid': charge.paid, 'status': charge.status} for charge in charges_data]
return {'id': intent.id, 'status': intent.status, 'amount': intent.amount / 100, 'currency': intent.currency, 'metadata': intent.metadata, 'charges': charges}
except stripe.StripeError as e:
raise ValueError(f'Stripe error: {str(e)}')
@staticmethod
async def confirm_payment(payment_intent_id: str, db: Session, booking_id: Optional[int]=None) -> Dict[str, Any]:
try:
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
if not booking_id and intent_data.get('metadata'):
booking_id = intent_data['metadata'].get('booking_id')
if booking_id:
booking_id = int(booking_id)
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')
payment_status = intent_data.get('status')
logger.info(f'Payment intent status: {payment_status}', extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id})
if payment_status not in ['succeeded', 'processing']:
raise ValueError(f'Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.')
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == payment_intent_id, Payment.payment_method == PaymentMethod.stripe).first()
if not payment:
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
amount = intent_data['amount']
if payment:
if payment_status == 'succeeded':
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
payment.amount = amount
payment.payment_method = PaymentMethod.stripe
else:
payment_type = PaymentType.full
if booking.requires_deposit and (not booking.deposit_paid):
payment_type = PaymentType.deposit
payment_status_enum = PaymentStatus.completed if payment_status == 'succeeded' else PaymentStatus.pending
payment_date = datetime.utcnow() if payment_status == 'succeeded' else None
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.stripe, payment_type=payment_type, payment_status=payment_status_enum, transaction_id=payment_intent_id, payment_date=payment_date, notes=f'Stripe payment - Intent: {payment_intent_id} (Status: {payment_status})')
db.add(payment)
db.commit()
db.refresh(payment)
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))
from ..models.invoice import Invoice, InvoiceStatus
from ..services.invoice_service import InvoiceService
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
for invoice in invoices:
invoice.amount_paid = total_paid
invoice.balance_due = float(invoice.total_amount) - total_paid
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.amount_paid > 0:
invoice.status = InvoiceStatus.sent
booking_was_confirmed = False
should_send_email = False
if payment.payment_type == PaymentType.deposit:
booking.deposit_paid = True
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
should_send_email = True
elif payment.payment_type == PaymentType.full:
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
should_send_email = True
if should_send_email:
try:
from ...shared.utils.mailer import send_email
from ...shared.utils.email_templates import booking_confirmation_email_template
from ...system.models.system_settings import SystemSettings
from ...rooms.models.room import Room
from sqlalchemy.orm import selectinload
import os
from ...shared.config.settings import settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbols = {'USD': '$', 'EUR': '', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '', 'INR': '', 'CHF': 'CHF', 'NZD': 'NZ$'}
currency_symbol = currency_symbols.get(currency, currency)
booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking_id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else 'Room'
amount_paid = total_paid
payment_type_str = payment.payment_type.value if payment.payment_type else None
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=False, deposit_amount=None, amount_paid=amount_paid, payment_type=payment_type_str, client_url=client_url, currency_symbol=currency_symbol)
if booking.user:
await send_email(to=booking.user.email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
logger.info(f'Booking confirmation email sent to {booking.user.email}')
except Exception as email_error:
logger.error(f'Failed to send booking confirmation email: {str(email_error)}')
from ...shared.utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
from ...auth.models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
if user:
await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
except Exception as email_error:
logger.error(f'Failed to send invoice email: {str(email_error)}')
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
try:
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 AttributeError as ae:
logger.error(f'AttributeError accessing payment fields: {ae}', exc_info=True, extra={
'payment_id': payment.id if hasattr(payment, 'id') else None,
'booking_id': booking_id,
'payment_method': payment.payment_method if hasattr(payment, 'payment_method') else 'missing',
'payment_type': payment.payment_type if hasattr(payment, 'payment_type') else 'missing',
'payment_status': payment.payment_status if hasattr(payment, 'payment_status') else 'missing'
})
raise
except ValueError as e:
db.rollback()
raise
except Exception as e:
error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}'
logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id})
db.rollback()
raise ValueError(f'Error confirming payment: {error_msg}')
@staticmethod
async def handle_webhook(payload: bytes, signature: str, db: Session) -> Dict[str, Any]:
webhook_secret = get_stripe_webhook_secret(db)
if not webhook_secret:
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
raise ValueError('Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.')
try:
event = stripe.Webhook.construct_event(payload, signature, webhook_secret)
except ValueError as e:
raise ValueError(f'Invalid payload: {str(e)}')
except stripe.SignatureVerificationError as e:
raise ValueError(f'Invalid signature: {str(e)}')
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
payment_intent_id = payment_intent['id']
metadata = payment_intent.get('metadata', {})
booking_id = metadata.get('booking_id')
if booking_id:
try:
await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=int(booking_id))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error processing webhook for booking {booking_id}: {str(e)}')
elif event['type'] == 'payment_intent.payment_failed':
payment_intent = event['data']['object']
payment_intent_id = payment_intent['id']
metadata = payment_intent.get('metadata', {})
booking_id = metadata.get('booking_id')
if booking_id:
payment = db.query(Payment).filter(Payment.transaction_id == payment_intent_id, Payment.booking_id == int(booking_id)).first()
if payment:
payment.payment_status = PaymentStatus.failed
db.commit()
booking = db.query(Booking).filter(Booking.id == int(booking_id)).first()
if booking and booking.status != BookingStatus.cancelled:
booking.status = BookingStatus.cancelled
db.commit()
db.refresh(booking)
try:
if booking.user:
from ...shared.utils.mailer import send_email
from ...shared.utils.email_templates import booking_status_changed_email_template
from ...system.models.system_settings import SystemSettings
from ...shared.config.settings import settings
import os
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url)
await send_email(to=booking.user.email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to send cancellation email: {e}')
return {'status': 'success', 'event_type': event['type'], 'event_id': event['id']}

View File

@@ -0,0 +1,11 @@
def create_payment_url(*args, **kwargs):
raise NotImplementedError('VNPay integration has been removed')
def verify_return(*args, **kwargs):
raise NotImplementedError('VNPay integration has been removed')
def sort_object(obj):
return {}
def create_signature(*args, **kwargs):
return ''