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