updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -91,6 +91,11 @@ class Settings(BaseSettings):
|
||||
# Health Check
|
||||
HEALTH_CHECK_INTERVAL: int = Field(default=30, description="Health check interval in seconds")
|
||||
|
||||
# Stripe Payment Gateway
|
||||
STRIPE_SECRET_KEY: str = Field(default="", description="Stripe secret key")
|
||||
STRIPE_PUBLISHABLE_KEY: str = Field(default="", description="Stripe publishable key")
|
||||
STRIPE_WEBHOOK_SECRET: str = Field(default="", description="Stripe webhook secret")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construct database URL"""
|
||||
|
||||
@@ -193,15 +193,17 @@ app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
# Import and include other routes
|
||||
from .routes import (
|
||||
room_routes, booking_routes, payment_routes, banner_routes,
|
||||
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
|
||||
favorite_routes, service_routes, promotion_routes, report_routes,
|
||||
review_routes, user_routes, audit_routes, admin_privacy_routes
|
||||
review_routes, user_routes, audit_routes, admin_privacy_routes,
|
||||
system_settings_routes
|
||||
)
|
||||
|
||||
# Legacy routes (maintain backward compatibility)
|
||||
app.include_router(room_routes.router, prefix="/api")
|
||||
app.include_router(booking_routes.router, prefix="/api")
|
||||
app.include_router(payment_routes.router, prefix="/api")
|
||||
app.include_router(invoice_routes.router, prefix="/api")
|
||||
app.include_router(banner_routes.router, prefix="/api")
|
||||
app.include_router(favorite_routes.router, prefix="/api")
|
||||
app.include_router(service_routes.router, prefix="/api")
|
||||
@@ -211,11 +213,13 @@ app.include_router(review_routes.router, prefix="/api")
|
||||
app.include_router(user_routes.router, prefix="/api")
|
||||
app.include_router(audit_routes.router, prefix="/api")
|
||||
app.include_router(admin_privacy_routes.router, prefix="/api")
|
||||
app.include_router(system_settings_routes.router, prefix="/api")
|
||||
|
||||
# Versioned routes (v1)
|
||||
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
@@ -225,6 +229,7 @@ app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
logger.info("All routes registered successfully")
|
||||
|
||||
|
||||
Binary file not shown.
@@ -16,6 +16,8 @@ from .favorite import Favorite
|
||||
from .audit_log import AuditLog
|
||||
from .cookie_policy import CookiePolicy
|
||||
from .cookie_integration_config import CookieIntegrationConfig
|
||||
from .system_settings import SystemSettings
|
||||
from .invoice import Invoice, InvoiceItem
|
||||
|
||||
__all__ = [
|
||||
"Role",
|
||||
@@ -36,5 +38,8 @@ __all__ = [
|
||||
"AuditLog",
|
||||
"CookiePolicy",
|
||||
"CookieIntegrationConfig",
|
||||
"SystemSettings",
|
||||
"Invoice",
|
||||
"InvoiceItem",
|
||||
]
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/invoice.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/invoice.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/system_settings.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/system_settings.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -35,6 +35,7 @@ class Booking(Base):
|
||||
user = relationship("User", back_populates="bookings")
|
||||
room = relationship("Room", back_populates="bookings")
|
||||
payments = relationship("Payment", back_populates="booking", cascade="all, delete-orphan")
|
||||
invoices = relationship("Invoice", back_populates="booking", cascade="all, delete-orphan")
|
||||
service_usages = relationship("ServiceUsage", back_populates="booking", cascade="all, delete-orphan")
|
||||
checkin_checkout = relationship("CheckInCheckOut", back_populates="booking", uselist=False)
|
||||
|
||||
|
||||
100
Backend/src/models/invoice.py
Normal file
100
Backend/src/models/invoice.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..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)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Invoice details
|
||||
issue_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
due_date = Column(DateTime, nullable=False)
|
||||
paid_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Amounts
|
||||
subtotal = Column(Numeric(10, 2), nullable=False, default=0.00)
|
||||
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00) # Tax percentage
|
||||
tax_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
|
||||
total_amount = Column(Numeric(10, 2), nullable=False)
|
||||
amount_paid = Column(Numeric(10, 2), nullable=False, default=0.00)
|
||||
balance_due = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Status
|
||||
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
|
||||
|
||||
# Company/Organization information (for admin to manage)
|
||||
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 information (snapshot at invoice creation)
|
||||
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)
|
||||
|
||||
# Additional information
|
||||
notes = Column(Text, nullable=True)
|
||||
terms_and_conditions = Column(Text, nullable=True)
|
||||
payment_instructions = Column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
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)
|
||||
|
||||
# Relationships
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Item details
|
||||
description = Column(String(500), nullable=False)
|
||||
quantity = Column(Numeric(10, 2), nullable=False, default=1.00)
|
||||
unit_price = Column(Numeric(10, 2), nullable=False)
|
||||
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
|
||||
line_total = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Optional reference to booking items
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=True)
|
||||
service_id = Column(Integer, ForeignKey("services.id"), nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
invoice = relationship("Invoice", back_populates="items")
|
||||
room = relationship("Room")
|
||||
service = relationship("Service")
|
||||
|
||||
@@ -11,6 +11,7 @@ class PaymentMethod(str, enum.Enum):
|
||||
debit_card = "debit_card"
|
||||
bank_transfer = "bank_transfer"
|
||||
e_wallet = "e_wallet"
|
||||
stripe = "stripe"
|
||||
|
||||
|
||||
class PaymentType(str, enum.Enum):
|
||||
|
||||
@@ -22,6 +22,9 @@ class Room(Base):
|
||||
status = Column(Enum(RoomStatus), nullable=False, default=RoomStatus.available)
|
||||
price = Column(Numeric(10, 2), nullable=False)
|
||||
featured = Column(Boolean, nullable=False, default=False)
|
||||
capacity = Column(Integer, nullable=True) # Room-specific capacity, overrides room_type capacity
|
||||
room_size = Column(String(50), nullable=True) # e.g., "1 Room", "2 Rooms", "50 sqm"
|
||||
view = Column(String(100), nullable=True) # e.g., "City View", "Ocean View", etc.
|
||||
images = Column(JSON, nullable=True)
|
||||
amenities = Column(JSON, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
21
Backend/src/models/system_settings.py
Normal file
21
Backend/src/models/system_settings.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class SystemSettings(Base):
|
||||
"""
|
||||
System-wide settings controlled by administrators.
|
||||
Stores key-value pairs for platform configuration like currency, etc.
|
||||
"""
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
value = Column(Text, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
updated_by = relationship("User", lazy="joined")
|
||||
|
||||
@@ -15,6 +15,7 @@ class User(Base):
|
||||
phone = Column(String(20), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
avatar = Column(String(255), nullable=True)
|
||||
currency = Column(String(3), nullable=False, default='VND') # ISO 4217 currency code
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -196,7 +196,8 @@ async def update_profile(
|
||||
email=profile_data.get("email"),
|
||||
phone_number=profile_data.get("phone_number"),
|
||||
password=profile_data.get("password"),
|
||||
current_password=profile_data.get("currentPassword")
|
||||
current_password=profile_data.get("currentPassword"),
|
||||
currency=profile_data.get("currency")
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from sqlalchemy import and_, or_
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
@@ -11,9 +11,11 @@ from ..config.settings import settings
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.room import Room
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..services.room_service import normalize_images, get_base_url
|
||||
from fastapi import Request
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import (
|
||||
booking_confirmation_email_template,
|
||||
@@ -129,6 +131,7 @@ async def get_all_bookings(
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_bookings(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -138,6 +141,7 @@ async def get_my_bookings(
|
||||
Booking.user_id == current_user.id
|
||||
).order_by(Booking.created_at.desc()).all()
|
||||
|
||||
base_url = get_base_url(request)
|
||||
result = []
|
||||
for booking in bookings:
|
||||
booking_dict = {
|
||||
@@ -157,11 +161,25 @@ async def get_my_bookings(
|
||||
|
||||
# Add room info
|
||||
if booking.room and booking.room.room_type:
|
||||
# Normalize room images if they exist
|
||||
room_images = []
|
||||
if booking.room.images:
|
||||
try:
|
||||
room_images = normalize_images(booking.room.images, base_url)
|
||||
except:
|
||||
room_images = []
|
||||
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
"floor": booking.room.floor,
|
||||
"images": room_images, # Include room images
|
||||
"room_type": {
|
||||
"id": booking.room.room_type.id,
|
||||
"name": booking.room.room_type.name,
|
||||
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
|
||||
"capacity": booking.room.room_type.capacity,
|
||||
"images": room_images, # Also include in room_type for backwards compatibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,10 +239,17 @@ async def create_booking(
|
||||
booking_number = generate_booking_number()
|
||||
|
||||
# Determine if deposit is required
|
||||
# Cash requires deposit, Stripe doesn't require deposit (full payment or deposit handled via payment flow)
|
||||
requires_deposit = payment_method == "cash"
|
||||
deposit_percentage = 20 if requires_deposit else 0
|
||||
deposit_amount = (float(total_price) * deposit_percentage) / 100 if requires_deposit else 0
|
||||
|
||||
# For Stripe, booking can be confirmed immediately after payment
|
||||
initial_status = BookingStatus.pending
|
||||
if payment_method == "stripe":
|
||||
# Will be confirmed after successful Stripe payment
|
||||
initial_status = BookingStatus.pending
|
||||
|
||||
# Create booking
|
||||
booking = Booking(
|
||||
booking_number=booking_number,
|
||||
@@ -235,7 +260,7 @@ async def create_booking(
|
||||
num_guests=guest_count,
|
||||
total_price=total_price,
|
||||
special_requests=notes,
|
||||
status=BookingStatus.pending,
|
||||
status=initial_status,
|
||||
requires_deposit=requires_deposit,
|
||||
deposit_paid=False,
|
||||
)
|
||||
@@ -243,24 +268,101 @@ async def create_booking(
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
|
||||
# Create deposit payment if required
|
||||
if requires_deposit:
|
||||
# Create payment record if Stripe payment method is selected
|
||||
if payment_method == "stripe":
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
|
||||
payment = Payment(
|
||||
booking_id=booking.id,
|
||||
amount=deposit_amount,
|
||||
payment_method=PaymentMethod.bank_transfer,
|
||||
payment_type=PaymentType.deposit,
|
||||
deposit_percentage=deposit_percentage,
|
||||
amount=total_price,
|
||||
payment_method=PaymentMethod.stripe,
|
||||
payment_type=PaymentType.full,
|
||||
payment_status=PaymentStatus.pending,
|
||||
notes=f"Deposit payment ({deposit_percentage}%) for booking {booking_number}",
|
||||
payment_date=None,
|
||||
)
|
||||
db.add(payment)
|
||||
db.flush()
|
||||
|
||||
# Create deposit payment if required (for cash method)
|
||||
# Note: For cash payments, deposit is paid on arrival, so we don't create a pending payment record
|
||||
# The payment will be created when the customer pays at check-in
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Fetch with relations
|
||||
booking = db.query(Booking).filter(Booking.id == booking.id).first()
|
||||
# Fetch with relations for proper serialization (eager load payments)
|
||||
from sqlalchemy.orm import joinedload
|
||||
booking = db.query(Booking).options(joinedload(Booking.payments)).filter(Booking.id == booking.id).first()
|
||||
|
||||
# Determine payment_method and payment_status from payments
|
||||
payment_method_from_payments = None
|
||||
payment_status_from_payments = "unpaid"
|
||||
if booking.payments:
|
||||
latest_payment = sorted(booking.payments, key=lambda p: p.created_at, reverse=True)[0]
|
||||
payment_method_from_payments = latest_payment.payment_method.value if isinstance(latest_payment.payment_method, PaymentMethod) else latest_payment.payment_method
|
||||
if latest_payment.payment_status == PaymentStatus.completed:
|
||||
payment_status_from_payments = "paid"
|
||||
elif latest_payment.payment_status == PaymentStatus.refunded:
|
||||
payment_status_from_payments = "refunded"
|
||||
|
||||
# Serialize booking properly
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
"guest_count": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"payment_method": payment_method_from_payments or payment_method,
|
||||
"payment_status": payment_status_from_payments,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"notes": booking.special_requests,
|
||||
"guest_info": {
|
||||
"full_name": current_user.full_name,
|
||||
"email": current_user.email,
|
||||
"phone": current_user.phone_number if hasattr(current_user, 'phone_number') else (current_user.phone if hasattr(current_user, 'phone') else ""),
|
||||
},
|
||||
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
}
|
||||
|
||||
# Add payments if they exist
|
||||
if booking.payments:
|
||||
booking_dict["payments"] = [
|
||||
{
|
||||
"id": p.id,
|
||||
"booking_id": p.booking_id,
|
||||
"amount": float(p.amount) if p.amount else 0.0,
|
||||
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method,
|
||||
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type,
|
||||
"deposit_percentage": p.deposit_percentage,
|
||||
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||
"transaction_id": p.transaction_id,
|
||||
"payment_date": p.payment_date.isoformat() if p.payment_date else None,
|
||||
"notes": p.notes,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
}
|
||||
for p in booking.payments
|
||||
]
|
||||
|
||||
# Add room info if available
|
||||
if booking.room:
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
"floor": booking.room.floor,
|
||||
}
|
||||
if booking.room.room_type:
|
||||
booking_dict["room"]["room_type"] = {
|
||||
"id": booking.room.room_type.id,
|
||||
"name": booking.room.room_type.name,
|
||||
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
|
||||
"capacity": booking.room.room_type.capacity,
|
||||
}
|
||||
|
||||
# Send booking confirmation email (non-blocking)
|
||||
try:
|
||||
@@ -291,7 +393,7 @@ async def create_booking(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"booking": booking},
|
||||
"data": {"booking": booking_dict},
|
||||
"message": f"Booking created. Please pay {deposit_percentage}% deposit to confirm." if requires_deposit else "Booking created successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
@@ -304,12 +406,22 @@ async def create_booking(
|
||||
@router.get("/{id}")
|
||||
async def get_booking_by_id(
|
||||
id: int,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get booking by ID"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
||||
# Eager load all relationships to avoid N+1 queries
|
||||
# Using selectinload for better performance with multiple relationships
|
||||
booking = db.query(Booking)\
|
||||
.options(
|
||||
selectinload(Booking.payments),
|
||||
joinedload(Booking.user),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
)\
|
||||
.filter(Booking.id == id)\
|
||||
.first()
|
||||
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
@@ -318,6 +430,19 @@ async def get_booking_by_id(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id: # Not admin
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Determine payment_method and payment_status from payments
|
||||
# Get latest payment efficiently (already loaded via joinedload)
|
||||
payment_method = None
|
||||
payment_status = "unpaid"
|
||||
if booking.payments:
|
||||
# Find latest payment (payments are already loaded, so this is fast)
|
||||
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||
payment_method = latest_payment.payment_method.value if isinstance(latest_payment.payment_method, PaymentMethod) else latest_payment.payment_method
|
||||
if latest_payment.payment_status == PaymentStatus.completed:
|
||||
payment_status = "paid"
|
||||
elif latest_payment.payment_status == PaymentStatus.refunded:
|
||||
payment_status = "refunded"
|
||||
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
@@ -325,24 +450,56 @@ async def get_booking_by_id(
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
"num_guests": booking.num_guests,
|
||||
"guest_count": booking.num_guests, # Frontend expects guest_count
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"payment_method": payment_method or "cash",
|
||||
"payment_status": payment_status,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"notes": booking.special_requests, # Frontend expects notes
|
||||
"guest_info": {
|
||||
"full_name": booking.user.full_name if booking.user else "",
|
||||
"email": booking.user.email if booking.user else "",
|
||||
"phone": booking.user.phone_number if booking.user and hasattr(booking.user, 'phone_number') else (booking.user.phone if booking.user and hasattr(booking.user, 'phone') else ""),
|
||||
} if booking.user else None,
|
||||
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
}
|
||||
|
||||
# Add relations
|
||||
# Only get base_url if we need it (room has images)
|
||||
if booking.room and booking.room.images:
|
||||
base_url = get_base_url(request)
|
||||
# Normalize room images if they exist
|
||||
try:
|
||||
room_images = normalize_images(booking.room.images, base_url)
|
||||
except:
|
||||
room_images = []
|
||||
else:
|
||||
room_images = []
|
||||
|
||||
if booking.room:
|
||||
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
"floor": booking.room.floor,
|
||||
"status": booking.room.status.value if isinstance(booking.room.status, RoomStatus) else booking.room.status,
|
||||
"images": room_images, # Include room images directly on room object
|
||||
}
|
||||
if booking.room.room_type:
|
||||
# Use room images if room_type doesn't have images (which is typical)
|
||||
# RoomType doesn't have images column, images are stored on Room
|
||||
room_type_images = room_images if room_images else []
|
||||
|
||||
booking_dict["room"]["room_type"] = {
|
||||
"id": booking.room.room_type.id,
|
||||
"name": booking.room.room_type.name,
|
||||
"base_price": float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0,
|
||||
"capacity": booking.room.room_type.capacity,
|
||||
"images": room_type_images,
|
||||
}
|
||||
|
||||
if booking.payments:
|
||||
@@ -385,6 +542,20 @@ async def cancel_booking(
|
||||
if booking.status == BookingStatus.cancelled:
|
||||
raise HTTPException(status_code=400, detail="Booking already cancelled")
|
||||
|
||||
# Prevent cancellation of confirmed bookings
|
||||
if booking.status == BookingStatus.confirmed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot cancel a confirmed booking. Please contact support for assistance."
|
||||
)
|
||||
|
||||
# Only allow cancellation of pending bookings
|
||||
if booking.status != BookingStatus.pending:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot cancel booking with status: {booking.status.value}. Only pending bookings can be cancelled."
|
||||
)
|
||||
|
||||
booking.status = BookingStatus.cancelled
|
||||
db.commit()
|
||||
|
||||
|
||||
249
Backend/src/routes/invoice_routes.py
Normal file
249
Backend/src/routes/invoice_routes.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.invoice import Invoice, InvoiceStatus
|
||||
from ..models.booking import Booking
|
||||
from ..services.invoice_service import InvoiceService
|
||||
|
||||
router = APIRouter(prefix="/invoices", tags=["invoices"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_invoices(
|
||||
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)
|
||||
):
|
||||
"""Get invoices for current user (or all invoices for admin)"""
|
||||
try:
|
||||
# Admin can see all invoices, users can only see their own
|
||||
user_id = None if current_user.role_id == 1 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 {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
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)
|
||||
):
|
||||
"""Get invoice by ID"""
|
||||
try:
|
||||
invoice = InvoiceService.get_invoice(id, db)
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
# Check access: admin can see all, users can only see their own
|
||||
if current_user.role_id != 1 and invoice["user_id"] != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"invoice": invoice}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_invoice(
|
||||
invoice_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new invoice from a booking (Admin/Staff only)"""
|
||||
try:
|
||||
# Only admin/staff can create invoices
|
||||
if current_user.role_id not in [1, 2]:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
booking_id = invoice_data.get("booking_id")
|
||||
if not booking_id:
|
||||
raise HTTPException(status_code=400, detail="booking_id is required")
|
||||
|
||||
# Check if booking exists
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
# Create invoice
|
||||
invoice = InvoiceService.create_invoice_from_booking(
|
||||
booking_id=booking_id,
|
||||
db=db,
|
||||
created_by_id=current_user.id,
|
||||
tax_rate=invoice_data.get("tax_rate", 0.0),
|
||||
discount_amount=invoice_data.get("discount_amount", 0.0),
|
||||
due_days=invoice_data.get("due_days", 30),
|
||||
company_name=invoice_data.get("company_name"),
|
||||
company_address=invoice_data.get("company_address"),
|
||||
company_phone=invoice_data.get("company_phone"),
|
||||
company_email=invoice_data.get("company_email"),
|
||||
company_tax_id=invoice_data.get("company_tax_id"),
|
||||
company_logo_url=invoice_data.get("company_logo_url"),
|
||||
customer_tax_id=invoice_data.get("customer_tax_id"),
|
||||
notes=invoice_data.get("notes"),
|
||||
terms_and_conditions=invoice_data.get("terms_and_conditions"),
|
||||
payment_instructions=invoice_data.get("payment_instructions"),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Invoice created successfully",
|
||||
"data": {"invoice": invoice}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_invoice(
|
||||
id: int,
|
||||
invoice_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an invoice (Admin/Staff only)"""
|
||||
try:
|
||||
invoice = db.query(Invoice).filter(Invoice.id == id).first()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
# Update invoice
|
||||
updated_invoice = InvoiceService.update_invoice(
|
||||
invoice_id=id,
|
||||
db=db,
|
||||
updated_by_id=current_user.id,
|
||||
**invoice_data
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Invoice updated successfully",
|
||||
"data": {"invoice": updated_invoice}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{id}/mark-paid")
|
||||
async def mark_invoice_as_paid(
|
||||
id: int,
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark an invoice as paid (Admin/Staff only)"""
|
||||
try:
|
||||
amount = payment_data.get("amount")
|
||||
|
||||
updated_invoice = InvoiceService.mark_invoice_as_paid(
|
||||
invoice_id=id,
|
||||
db=db,
|
||||
amount=amount,
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Invoice marked as paid successfully",
|
||||
"data": {"invoice": updated_invoice}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
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)
|
||||
):
|
||||
"""Delete an invoice (Admin only)"""
|
||||
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 {
|
||||
"status": "success",
|
||||
"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)
|
||||
):
|
||||
"""Get all invoices for a specific booking"""
|
||||
try:
|
||||
# Check if booking exists and user has access
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
# Check access: admin can see all, users can only see their own bookings
|
||||
if current_user.role_id != 1 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 {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
@@ -12,6 +12,7 @@ from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import payment_confirmation_email_template
|
||||
from ..services.stripe_service import StripeService
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
@@ -340,3 +341,250 @@ async def update_payment_status(
|
||||
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: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a Stripe payment intent"""
|
||||
try:
|
||||
# Check if Stripe is configured (from database or environment)
|
||||
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.get("booking_id")
|
||||
amount = float(intent_data.get("amount", 0))
|
||||
currency = intent_data.get("currency", "usd")
|
||||
|
||||
# Log the incoming amount for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}")
|
||||
|
||||
if not booking_id or amount <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="booking_id and amount are required"
|
||||
)
|
||||
|
||||
# Validate amount is reasonable (Stripe max is $999,999.99)
|
||||
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."
|
||||
)
|
||||
|
||||
# Verify booking exists and user has access
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Create payment intent
|
||||
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
|
||||
)
|
||||
|
||||
# Get publishable key from database or environment
|
||||
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 {
|
||||
"status": "success",
|
||||
"message": "Payment intent created successfully",
|
||||
"data": {
|
||||
"client_secret": intent["client_secret"],
|
||||
"payment_intent_id": intent["id"],
|
||||
"publishable_key": publishable_key,
|
||||
}
|
||||
}
|
||||
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)
|
||||
):
|
||||
"""Confirm a Stripe payment"""
|
||||
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"
|
||||
)
|
||||
|
||||
# Confirm payment (this commits the transaction internally)
|
||||
payment = StripeService.confirm_payment(
|
||||
payment_intent_id=payment_intent_id,
|
||||
db=db,
|
||||
booking_id=booking_id
|
||||
)
|
||||
|
||||
# Ensure the transaction is committed before proceeding
|
||||
# The service method already commits, but we ensure it here too
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
# If already committed, this will raise an error, which we can ignore
|
||||
pass
|
||||
|
||||
# Get fresh booking from database to get updated status (after commit)
|
||||
booking = db.query(Booking).filter(Booking.id == payment["booking_id"]).first()
|
||||
if booking:
|
||||
db.refresh(booking)
|
||||
|
||||
# Send payment confirmation email (non-blocking, after commit)
|
||||
# This won't affect the transaction since it's already committed
|
||||
if booking and booking.user:
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
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"],
|
||||
client_url=client_url
|
||||
)
|
||||
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 {
|
||||
"status": "success",
|
||||
"message": "Payment confirmed successfully",
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
):
|
||||
"""Handle Stripe webhook events"""
|
||||
try:
|
||||
# Check if webhook secret is configured (from database or environment)
|
||||
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 = StripeService.handle_webhook(
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"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))
|
||||
|
||||
@@ -197,7 +197,7 @@ async def search_available_rooms(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
@router.get("/id/{id}")
|
||||
async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db)):
|
||||
"""Get room by ID"""
|
||||
try:
|
||||
@@ -225,9 +225,81 @@ async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
|
||||
"price": float(room.price) if room.price else 0.0,
|
||||
"price": float(room.price) if room.price is not None and room.price > 0 else None,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"capacity": room.capacity,
|
||||
"room_size": room.room_size,
|
||||
"view": room.view,
|
||||
"amenities": room.amenities,
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
|
||||
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
|
||||
}
|
||||
|
||||
# Normalize images
|
||||
try:
|
||||
room_dict["images"] = normalize_images(room.images, base_url)
|
||||
except:
|
||||
room_dict["images"] = []
|
||||
|
||||
# Add room type
|
||||
if room.room_type:
|
||||
room_dict["room_type"] = {
|
||||
"id": room.room_type.id,
|
||||
"name": room.room_type.name,
|
||||
"description": room.room_type.description,
|
||||
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
|
||||
"capacity": room.room_type.capacity,
|
||||
"amenities": room.room_type.amenities,
|
||||
"images": [] # RoomType doesn't have images column in DB
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"room": room_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{room_number}")
|
||||
async def get_room_by_number(room_number: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Get room by room number"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.room_number == room_number).first()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Get review stats
|
||||
review_stats = db.query(
|
||||
func.avg(Review.rating).label('average_rating'),
|
||||
func.count(Review.id).label('total_reviews')
|
||||
).filter(
|
||||
and_(
|
||||
Review.room_id == room.id,
|
||||
Review.status == ReviewStatus.approved
|
||||
)
|
||||
).first()
|
||||
|
||||
base_url = get_base_url(request)
|
||||
|
||||
room_dict = {
|
||||
"id": room.id,
|
||||
"room_type_id": room.room_type_id,
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
|
||||
"price": float(room.price) if room.price is not None and room.price > 0 else None,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"capacity": room.capacity,
|
||||
"room_size": room.room_size,
|
||||
"view": room.view,
|
||||
"amenities": room.amenities,
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
@@ -266,6 +338,7 @@ async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db
|
||||
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def create_room(
|
||||
room_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -281,6 +354,13 @@ async def create_room(
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Room number already exists")
|
||||
|
||||
# Ensure amenities is always a list
|
||||
amenities_value = room_data.get("amenities", [])
|
||||
if amenities_value is None:
|
||||
amenities_value = []
|
||||
elif not isinstance(amenities_value, list):
|
||||
amenities_value = []
|
||||
|
||||
room = Room(
|
||||
room_type_id=room_data.get("room_type_id"),
|
||||
room_number=room_data.get("room_number"),
|
||||
@@ -288,16 +368,60 @@ async def create_room(
|
||||
status=RoomStatus(room_data.get("status", "available")),
|
||||
featured=room_data.get("featured", False),
|
||||
price=room_data.get("price", room_type.base_price),
|
||||
description=room_data.get("description"),
|
||||
capacity=room_data.get("capacity"),
|
||||
room_size=room_data.get("room_size"),
|
||||
view=room_data.get("view"),
|
||||
amenities=amenities_value,
|
||||
)
|
||||
|
||||
db.add(room)
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
# Get base URL for proper response
|
||||
base_url = get_base_url(request)
|
||||
|
||||
# Serialize room data
|
||||
room_dict = {
|
||||
"id": room.id,
|
||||
"room_type_id": room.room_type_id,
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
|
||||
"price": float(room.price) if room.price is not None and room.price > 0 else None,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"capacity": room.capacity,
|
||||
"room_size": room.room_size,
|
||||
"view": room.view,
|
||||
"amenities": room.amenities if room.amenities else [],
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
}
|
||||
|
||||
# Normalize images
|
||||
try:
|
||||
room_dict["images"] = normalize_images(room.images, base_url)
|
||||
except:
|
||||
room_dict["images"] = []
|
||||
|
||||
# Add room type info
|
||||
if room.room_type:
|
||||
room_dict["room_type"] = {
|
||||
"id": room.room_type.id,
|
||||
"name": room.room_type.name,
|
||||
"description": room.room_type.description,
|
||||
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
|
||||
"capacity": room.room_type.capacity,
|
||||
"amenities": room.room_type.amenities if room.room_type.amenities else [],
|
||||
"images": []
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Room created successfully",
|
||||
"data": {"room": room}
|
||||
"data": {"room": room_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -310,6 +434,7 @@ async def create_room(
|
||||
async def update_room(
|
||||
id: int,
|
||||
room_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -337,14 +462,70 @@ async def update_room(
|
||||
room.featured = room_data["featured"]
|
||||
if "price" in room_data:
|
||||
room.price = room_data["price"]
|
||||
if "description" in room_data:
|
||||
room.description = room_data["description"]
|
||||
if "capacity" in room_data:
|
||||
room.capacity = room_data["capacity"]
|
||||
if "room_size" in room_data:
|
||||
room.room_size = room_data["room_size"]
|
||||
if "view" in room_data:
|
||||
room.view = room_data["view"]
|
||||
if "amenities" in room_data:
|
||||
# Ensure amenities is always a list
|
||||
amenities_value = room_data["amenities"]
|
||||
if amenities_value is None:
|
||||
room.amenities = []
|
||||
elif isinstance(amenities_value, list):
|
||||
room.amenities = amenities_value
|
||||
else:
|
||||
room.amenities = []
|
||||
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
# Get base URL for proper response
|
||||
base_url = get_base_url(request)
|
||||
|
||||
# Serialize room data similar to get_room_by_id
|
||||
room_dict = {
|
||||
"id": room.id,
|
||||
"room_type_id": room.room_type_id,
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
|
||||
"price": float(room.price) if room.price is not None and room.price > 0 else None,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"capacity": room.capacity,
|
||||
"room_size": room.room_size,
|
||||
"view": room.view,
|
||||
"amenities": room.amenities if room.amenities else [],
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
}
|
||||
|
||||
# Normalize images
|
||||
try:
|
||||
room_dict["images"] = normalize_images(room.images, base_url)
|
||||
except:
|
||||
room_dict["images"] = []
|
||||
|
||||
# Add room type info
|
||||
if room.room_type:
|
||||
room_dict["room_type"] = {
|
||||
"id": room.room_type.id,
|
||||
"name": room.room_type.name,
|
||||
"description": room.room_type.description,
|
||||
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
|
||||
"capacity": room.room_type.capacity,
|
||||
"amenities": room.room_type.amenities if room.room_type.amenities else [],
|
||||
"images": []
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Room updated successfully",
|
||||
"data": {"room": room}
|
||||
"data": {"room": room_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -379,6 +560,57 @@ async def delete_room(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/bulk-delete", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def bulk_delete_rooms(
|
||||
room_ids: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Bulk delete rooms (Admin only)"""
|
||||
try:
|
||||
ids = room_ids.get("ids", [])
|
||||
if not ids or not isinstance(ids, list):
|
||||
raise HTTPException(status_code=400, detail="Invalid room IDs provided")
|
||||
|
||||
if len(ids) == 0:
|
||||
raise HTTPException(status_code=400, detail="No room IDs provided")
|
||||
|
||||
# Validate all IDs are integers
|
||||
try:
|
||||
ids = [int(id) for id in ids]
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="All room IDs must be integers")
|
||||
|
||||
# Check if all rooms exist
|
||||
rooms = db.query(Room).filter(Room.id.in_(ids)).all()
|
||||
found_ids = [room.id for room in rooms]
|
||||
not_found_ids = [id for id in ids if id not in found_ids]
|
||||
|
||||
if not_found_ids:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Rooms with IDs {not_found_ids} not found"
|
||||
)
|
||||
|
||||
# Delete all rooms
|
||||
deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully deleted {deleted_count} room(s)",
|
||||
"data": {
|
||||
"deleted_count": deleted_count,
|
||||
"deleted_ids": ids
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))])
|
||||
async def upload_room_images(
|
||||
id: int,
|
||||
@@ -435,7 +667,7 @@ async def upload_room_images(
|
||||
@router.delete("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))])
|
||||
async def delete_room_images(
|
||||
id: int,
|
||||
image_url: str,
|
||||
image_url: str = Query(..., description="Image URL or path to delete"),
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -445,12 +677,39 @@ async def delete_room_images(
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Update room images (images are stored on Room, not RoomType)
|
||||
# Normalize the incoming image_url to extract the path
|
||||
# Handle both full URLs and relative paths
|
||||
normalized_url = image_url
|
||||
if image_url.startswith('http://') or image_url.startswith('https://'):
|
||||
# Extract path from URL
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(image_url)
|
||||
normalized_url = parsed.path
|
||||
|
||||
# Normalize paths for comparison (ensure leading slash)
|
||||
if not normalized_url.startswith('/'):
|
||||
normalized_url = f"/{normalized_url}"
|
||||
|
||||
# Get filename from normalized path
|
||||
filename = Path(normalized_url).name
|
||||
|
||||
# Update room images - compare by filename or full path
|
||||
existing_images = room.images or []
|
||||
updated_images = [img for img in existing_images if img != image_url]
|
||||
updated_images = []
|
||||
|
||||
for img in existing_images:
|
||||
# Normalize stored image path
|
||||
stored_path = img if img.startswith('/') else f"/{img}"
|
||||
stored_filename = Path(stored_path).name
|
||||
|
||||
# Compare by filename or full path
|
||||
# Keep images that don't match
|
||||
if (img != normalized_url and
|
||||
stored_path != normalized_url and
|
||||
stored_filename != filename):
|
||||
updated_images.append(img)
|
||||
|
||||
# Delete file from disk
|
||||
filename = Path(image_url).name
|
||||
file_path = Path(__file__).parent.parent.parent / "uploads" / "rooms" / filename
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
302
Backend/src/routes/system_settings_routes.py
Normal file
302
Backend/src/routes/system_settings_routes.py
Normal file
@@ -0,0 +1,302 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
|
||||
|
||||
|
||||
@router.get("/currency")
|
||||
async def get_platform_currency(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get platform currency setting (public endpoint for frontend)"""
|
||||
try:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "platform_currency"
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
# Default to VND if not set
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"currency": "VND",
|
||||
"updated_at": None,
|
||||
"updated_by": None
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"currency": setting.value,
|
||||
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
|
||||
"updated_by": setting.updated_by.full_name if setting.updated_by else None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/currency")
|
||||
async def update_platform_currency(
|
||||
currency_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update platform currency (Admin only)"""
|
||||
try:
|
||||
currency = currency_data.get("currency", "").upper()
|
||||
|
||||
# Validate currency code
|
||||
if not currency or len(currency) != 3 or not currency.isalpha():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid currency code. Must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)"
|
||||
)
|
||||
|
||||
# Get or create setting
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "platform_currency"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = currency
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="platform_currency",
|
||||
value=currency,
|
||||
description="Platform-wide currency setting for displaying prices across the application",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Platform currency updated successfully",
|
||||
"data": {
|
||||
"currency": setting.value,
|
||||
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
|
||||
"updated_by": setting.updated_by.full_name if setting.updated_by else None
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_all_settings(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all system settings (Admin only)"""
|
||||
try:
|
||||
settings = db.query(SystemSettings).all()
|
||||
|
||||
result = []
|
||||
for setting in settings:
|
||||
result.append({
|
||||
"key": setting.key,
|
||||
"value": setting.value,
|
||||
"description": setting.description,
|
||||
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
|
||||
"updated_by": setting.updated_by.full_name if setting.updated_by else None
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"settings": result
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stripe")
|
||||
async def get_stripe_settings(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Stripe payment settings (Admin only)"""
|
||||
try:
|
||||
secret_key_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_secret_key"
|
||||
).first()
|
||||
|
||||
publishable_key_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_publishable_key"
|
||||
).first()
|
||||
|
||||
webhook_secret_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_webhook_secret"
|
||||
).first()
|
||||
|
||||
# Mask secret keys for security (only show last 4 characters)
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return ""
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
result = {
|
||||
"stripe_secret_key": "",
|
||||
"stripe_publishable_key": "",
|
||||
"stripe_webhook_secret": "",
|
||||
"stripe_secret_key_masked": "",
|
||||
"stripe_webhook_secret_masked": "",
|
||||
"has_secret_key": False,
|
||||
"has_publishable_key": False,
|
||||
"has_webhook_secret": False,
|
||||
}
|
||||
|
||||
if secret_key_setting:
|
||||
result["stripe_secret_key"] = secret_key_setting.value
|
||||
result["stripe_secret_key_masked"] = mask_key(secret_key_setting.value)
|
||||
result["has_secret_key"] = bool(secret_key_setting.value)
|
||||
result["updated_at"] = secret_key_setting.updated_at.isoformat() if secret_key_setting.updated_at else None
|
||||
result["updated_by"] = secret_key_setting.updated_by.full_name if secret_key_setting.updated_by else None
|
||||
|
||||
if publishable_key_setting:
|
||||
result["stripe_publishable_key"] = publishable_key_setting.value
|
||||
result["has_publishable_key"] = bool(publishable_key_setting.value)
|
||||
|
||||
if webhook_secret_setting:
|
||||
result["stripe_webhook_secret"] = webhook_secret_setting.value
|
||||
result["stripe_webhook_secret_masked"] = mask_key(webhook_secret_setting.value)
|
||||
result["has_webhook_secret"] = bool(webhook_secret_setting.value)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/stripe")
|
||||
async def update_stripe_settings(
|
||||
stripe_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update Stripe payment settings (Admin only)"""
|
||||
try:
|
||||
secret_key = stripe_data.get("stripe_secret_key", "").strip()
|
||||
publishable_key = stripe_data.get("stripe_publishable_key", "").strip()
|
||||
webhook_secret = stripe_data.get("stripe_webhook_secret", "").strip()
|
||||
|
||||
# Validate secret key format (should start with sk_)
|
||||
if secret_key and not secret_key.startswith("sk_"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid Stripe secret key format. Must start with 'sk_'"
|
||||
)
|
||||
|
||||
# Validate publishable key format (should start with pk_)
|
||||
if publishable_key and not publishable_key.startswith("pk_"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid Stripe publishable key format. Must start with 'pk_'"
|
||||
)
|
||||
|
||||
# Validate webhook secret format (should start with whsec_)
|
||||
if webhook_secret and not webhook_secret.startswith("whsec_"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid Stripe webhook secret format. Must start with 'whsec_'"
|
||||
)
|
||||
|
||||
# Update or create secret key setting
|
||||
if secret_key:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_secret_key"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = secret_key
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="stripe_secret_key",
|
||||
value=secret_key,
|
||||
description="Stripe secret key for processing payments",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
# Update or create publishable key setting
|
||||
if publishable_key:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_publishable_key"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = publishable_key
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="stripe_publishable_key",
|
||||
value=publishable_key,
|
||||
description="Stripe publishable key for frontend payment forms",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
# Update or create webhook secret setting
|
||||
if webhook_secret:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_webhook_secret"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = webhook_secret
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="stripe_webhook_secret",
|
||||
value=webhook_secret,
|
||||
description="Stripe webhook secret for verifying webhook events",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Return masked values
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return ""
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Stripe settings updated successfully",
|
||||
"data": {
|
||||
"stripe_secret_key": secret_key if secret_key else "",
|
||||
"stripe_publishable_key": publishable_key,
|
||||
"stripe_webhook_secret": webhook_secret if webhook_secret else "",
|
||||
"stripe_secret_key_masked": mask_key(secret_key) if secret_key else "",
|
||||
"stripe_webhook_secret_masked": mask_key(webhook_secret) if webhook_secret else "",
|
||||
"has_secret_key": bool(secret_key),
|
||||
"has_publishable_key": bool(publishable_key),
|
||||
"has_webhook_secret": bool(webhook_secret),
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -66,6 +66,7 @@ async def get_users(
|
||||
"phone_number": user.phone, # For frontend compatibility
|
||||
"address": user.address,
|
||||
"avatar": user.avatar,
|
||||
"currency": getattr(user, 'currency', 'VND'),
|
||||
"is_active": user.is_active,
|
||||
"status": "active" if user.is_active else "inactive",
|
||||
"role_id": user.role_id,
|
||||
@@ -117,6 +118,7 @@ async def get_user_by_id(
|
||||
"phone_number": user.phone,
|
||||
"address": user.address,
|
||||
"avatar": user.avatar,
|
||||
"currency": getattr(user, 'currency', 'VND'),
|
||||
"is_active": user.is_active,
|
||||
"status": "active" if user.is_active else "inactive",
|
||||
"role_id": user.role_id,
|
||||
@@ -194,6 +196,7 @@ async def create_user(
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
"phone_number": user.phone,
|
||||
"currency": getattr(user, 'currency', 'VND'),
|
||||
"role_id": user.role_id,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
@@ -248,6 +251,10 @@ async def update_user(
|
||||
user.role_id = role_map.get(user_data["role"], 3)
|
||||
if "status" in user_data and current_user.role_id == 1:
|
||||
user.is_active = user_data["status"] == "active"
|
||||
if "currency" in user_data:
|
||||
currency = user_data["currency"]
|
||||
if len(currency) == 3 and currency.isalpha():
|
||||
user.currency = currency.upper()
|
||||
if "password" in user_data:
|
||||
password_bytes = user_data["password"].encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
@@ -263,6 +270,7 @@ async def update_user(
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
"phone_number": user.phone,
|
||||
"currency": getattr(user, 'currency', 'VND'),
|
||||
"role_id": user.role_id,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/services/__pycache__/invoice_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/invoice_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/services/__pycache__/stripe_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/stripe_service.cpython-312.pyc
Normal file
Binary file not shown.
@@ -81,6 +81,7 @@ class AuthService:
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"avatar": user.avatar,
|
||||
"currency": getattr(user, 'currency', 'VND'),
|
||||
"role": user.role.name if user.role else "customer",
|
||||
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
||||
"updatedAt": user.updated_at.isoformat() if user.updated_at else None,
|
||||
@@ -265,7 +266,8 @@ class AuthService:
|
||||
email: Optional[str] = None,
|
||||
phone_number: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
current_password: Optional[str] = None
|
||||
current_password: Optional[str] = None,
|
||||
currency: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Update user profile"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -295,6 +297,12 @@ class AuthService:
|
||||
user.email = email
|
||||
if phone_number is not None:
|
||||
user.phone = phone_number
|
||||
if currency is not None:
|
||||
# Validate currency code (ISO 4217, 3 characters)
|
||||
if len(currency) == 3 and currency.isalpha():
|
||||
user.currency = currency.upper()
|
||||
else:
|
||||
raise ValueError("Invalid currency code. Must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)")
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
101
Backend/src/services/currency_service.py
Normal file
101
Backend/src/services/currency_service.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Currency conversion service
|
||||
Handles currency conversion between different currencies
|
||||
"""
|
||||
from typing import Dict
|
||||
from decimal import Decimal
|
||||
|
||||
# Base currency is VND (Vietnamese Dong)
|
||||
# Exchange rates relative to VND (1 VND = base)
|
||||
# These are approximate rates - in production, fetch from an API like exchangerate-api.com
|
||||
EXCHANGE_RATES: Dict[str, Decimal] = {
|
||||
'VND': Decimal('1.0'), # Base currency
|
||||
'USD': Decimal('0.000041'), # 1 VND = 0.000041 USD (approx 24,000 VND = 1 USD)
|
||||
'EUR': Decimal('0.000038'), # 1 VND = 0.000038 EUR (approx 26,000 VND = 1 EUR)
|
||||
'GBP': Decimal('0.000033'), # 1 VND = 0.000033 GBP (approx 30,000 VND = 1 GBP)
|
||||
'JPY': Decimal('0.0061'), # 1 VND = 0.0061 JPY (approx 164 VND = 1 JPY)
|
||||
'CNY': Decimal('0.00029'), # 1 VND = 0.00029 CNY (approx 3,400 VND = 1 CNY)
|
||||
'KRW': Decimal('0.055'), # 1 VND = 0.055 KRW (approx 18 VND = 1 KRW)
|
||||
'SGD': Decimal('0.000055'), # 1 VND = 0.000055 SGD (approx 18,000 VND = 1 SGD)
|
||||
'THB': Decimal('0.0015'), # 1 VND = 0.0015 THB (approx 667 VND = 1 THB)
|
||||
'AUD': Decimal('0.000062'), # 1 VND = 0.000062 AUD (approx 16,000 VND = 1 AUD)
|
||||
'CAD': Decimal('0.000056'), # 1 VND = 0.000056 CAD (approx 18,000 VND = 1 CAD)
|
||||
}
|
||||
|
||||
# Supported currencies list
|
||||
SUPPORTED_CURRENCIES = list(EXCHANGE_RATES.keys())
|
||||
|
||||
|
||||
class CurrencyService:
|
||||
"""Service for currency conversion"""
|
||||
|
||||
@staticmethod
|
||||
def get_supported_currencies() -> list:
|
||||
"""Get list of supported currency codes"""
|
||||
return SUPPORTED_CURRENCIES
|
||||
|
||||
@staticmethod
|
||||
def convert_amount(amount: float, from_currency: str, to_currency: str) -> float:
|
||||
"""
|
||||
Convert amount from one currency to another
|
||||
|
||||
Args:
|
||||
amount: Amount to convert
|
||||
from_currency: Source currency code (ISO 4217)
|
||||
to_currency: Target currency code (ISO 4217)
|
||||
|
||||
Returns:
|
||||
Converted amount
|
||||
"""
|
||||
from_currency = from_currency.upper()
|
||||
to_currency = to_currency.upper()
|
||||
|
||||
if from_currency == to_currency:
|
||||
return amount
|
||||
|
||||
if from_currency not in EXCHANGE_RATES:
|
||||
raise ValueError(f"Unsupported source currency: {from_currency}")
|
||||
if to_currency not in EXCHANGE_RATES:
|
||||
raise ValueError(f"Unsupported target currency: {to_currency}")
|
||||
|
||||
# Convert to VND first, then to target currency
|
||||
amount_vnd = Decimal(str(amount)) / EXCHANGE_RATES[from_currency]
|
||||
converted_amount = amount_vnd * EXCHANGE_RATES[to_currency]
|
||||
|
||||
return float(converted_amount)
|
||||
|
||||
@staticmethod
|
||||
def get_exchange_rate(from_currency: str, to_currency: str) -> float:
|
||||
"""
|
||||
Get exchange rate between two currencies
|
||||
|
||||
Args:
|
||||
from_currency: Source currency code
|
||||
to_currency: Target currency code
|
||||
|
||||
Returns:
|
||||
Exchange rate (1 from_currency = X to_currency)
|
||||
"""
|
||||
from_currency = from_currency.upper()
|
||||
to_currency = to_currency.upper()
|
||||
|
||||
if from_currency == to_currency:
|
||||
return 1.0
|
||||
|
||||
if from_currency not in EXCHANGE_RATES:
|
||||
raise ValueError(f"Unsupported source currency: {from_currency}")
|
||||
if to_currency not in EXCHANGE_RATES:
|
||||
raise ValueError(f"Unsupported target currency: {to_currency}")
|
||||
|
||||
# Rate = (1 / from_rate) * to_rate
|
||||
rate = EXCHANGE_RATES[to_currency] / EXCHANGE_RATES[from_currency]
|
||||
return float(rate)
|
||||
|
||||
@staticmethod
|
||||
def format_currency_code(currency: str) -> str:
|
||||
"""Format currency code to uppercase"""
|
||||
return currency.upper() if currency else 'VND'
|
||||
|
||||
|
||||
currency_service = CurrencyService()
|
||||
|
||||
388
Backend/src/services/invoice_service.py
Normal file
388
Backend/src/services/invoice_service.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Invoice service for managing invoices
|
||||
"""
|
||||
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 ..models.booking import Booking
|
||||
from ..models.payment import Payment, PaymentStatus
|
||||
from ..models.user import User
|
||||
|
||||
|
||||
def generate_invoice_number(db: Session) -> str:
|
||||
"""Generate a unique invoice number"""
|
||||
# Format: INV-YYYYMMDD-XXXX
|
||||
today = datetime.utcnow().strftime("%Y%m%d")
|
||||
|
||||
# Get the last invoice number for today
|
||||
last_invoice = db.query(Invoice).filter(
|
||||
Invoice.invoice_number.like(f"INV-{today}-%")
|
||||
).order_by(Invoice.invoice_number.desc()).first()
|
||||
|
||||
if last_invoice:
|
||||
# Extract the sequence number and increment
|
||||
try:
|
||||
sequence = int(last_invoice.invoice_number.split("-")[-1])
|
||||
sequence += 1
|
||||
except (ValueError, IndexError):
|
||||
sequence = 1
|
||||
else:
|
||||
sequence = 1
|
||||
|
||||
return f"INV-{today}-{sequence:04d}"
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
"""Service for managing invoices"""
|
||||
|
||||
@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,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an invoice from a booking
|
||||
|
||||
Args:
|
||||
booking_id: Booking ID
|
||||
db: Database session
|
||||
created_by_id: User ID who created the invoice
|
||||
tax_rate: Tax rate percentage (default: 0.0)
|
||||
discount_amount: Discount amount (default: 0.0)
|
||||
due_days: Number of days until due date (default: 30)
|
||||
**kwargs: Additional invoice fields (company info, notes, etc.)
|
||||
|
||||
Returns:
|
||||
Invoice dictionary
|
||||
"""
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise ValueError("Booking not found")
|
||||
|
||||
user = db.query(User).filter(User.id == booking.user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Generate invoice number
|
||||
invoice_number = generate_invoice_number(db)
|
||||
|
||||
# Calculate amounts
|
||||
subtotal = float(booking.total_price)
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
total_amount = subtotal + tax_amount - discount_amount
|
||||
|
||||
# Calculate amount paid from completed payments
|
||||
amount_paid = sum(
|
||||
float(p.amount) for p in booking.payments
|
||||
if p.payment_status == PaymentStatus.completed
|
||||
)
|
||||
balance_due = total_amount - amount_paid
|
||||
|
||||
# Determine status
|
||||
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
|
||||
|
||||
# Create invoice
|
||||
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,
|
||||
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"),
|
||||
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)
|
||||
|
||||
# Create invoice items from booking
|
||||
# Room item
|
||||
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'}",
|
||||
quantity=1,
|
||||
unit_price=float(booking.total_price),
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=0.0,
|
||||
line_total=float(booking.total_price),
|
||||
room_id=booking.room_id,
|
||||
)
|
||||
db.add(room_item)
|
||||
|
||||
# Add service items if any
|
||||
for service_usage in booking.service_usages:
|
||||
service_item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description=f"Service: {service_usage.service.name}",
|
||||
quantity=float(service_usage.quantity),
|
||||
unit_price=float(service_usage.service.price),
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=0.0,
|
||||
line_total=float(service_usage.quantity) * float(service_usage.service.price),
|
||||
service_id=service_usage.service_id,
|
||||
)
|
||||
db.add(service_item)
|
||||
subtotal += float(service_usage.quantity) * float(service_usage.service.price)
|
||||
|
||||
# Recalculate totals if services were added
|
||||
if booking.service_usages:
|
||||
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,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an invoice
|
||||
|
||||
Args:
|
||||
invoice_id: Invoice ID
|
||||
db: Database session
|
||||
updated_by_id: User ID who updated the invoice
|
||||
**kwargs: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated invoice dictionary
|
||||
"""
|
||||
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
|
||||
if not invoice:
|
||||
raise ValueError("Invoice not found")
|
||||
|
||||
# Update allowed fields
|
||||
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])
|
||||
|
||||
# Recalculate if tax_rate or discount_amount changed
|
||||
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)
|
||||
|
||||
invoice.tax_amount = (invoice.subtotal - discount_amount) * (float(tax_rate) / 100)
|
||||
invoice.total_amount = invoice.subtotal + invoice.tax_amount - discount_amount
|
||||
invoice.balance_due = invoice.total_amount - invoice.amount_paid
|
||||
|
||||
# Update status based on balance
|
||||
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
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mark an invoice as paid
|
||||
|
||||
Args:
|
||||
invoice_id: Invoice ID
|
||||
db: Database session
|
||||
amount: Payment amount (if None, uses balance_due)
|
||||
updated_by_id: User ID who marked as paid
|
||||
|
||||
Returns:
|
||||
Updated invoice dictionary
|
||||
"""
|
||||
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]]:
|
||||
"""Get invoice by ID"""
|
||||
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]:
|
||||
"""
|
||||
Get invoices with filters
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Filter by user ID
|
||||
booking_id: Filter by booking ID
|
||||
status: Filter by status
|
||||
page: Page number
|
||||
limit: Items per page
|
||||
|
||||
Returns:
|
||||
Dictionary with invoices and pagination info
|
||||
"""
|
||||
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
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
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]:
|
||||
"""Convert invoice model to dictionary"""
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,20 @@ def normalize_images(images, base_url: str) -> List[str]:
|
||||
|
||||
def get_base_url(request) -> str:
|
||||
"""Get base URL for image normalization"""
|
||||
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:3000')}"
|
||||
# Try to get from environment first
|
||||
server_url = os.getenv("SERVER_URL")
|
||||
if server_url:
|
||||
return server_url.rstrip('/')
|
||||
|
||||
# Get from request host header
|
||||
host = request.headers.get('host', 'localhost:8000')
|
||||
# Ensure we use the backend port if host doesn't have a port
|
||||
if ':' not in host:
|
||||
host = f"{host}:8000"
|
||||
|
||||
# Use http or https based on scheme
|
||||
scheme = request.url.scheme if hasattr(request.url, 'scheme') else 'http'
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
async def get_rooms_with_ratings(
|
||||
@@ -72,6 +85,9 @@ async def get_rooms_with_ratings(
|
||||
"price": float(room.price) if room.price else 0.0,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"capacity": room.capacity,
|
||||
"room_size": room.room_size,
|
||||
"view": room.view,
|
||||
"amenities": room.amenities,
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
@@ -102,44 +118,319 @@ async def get_rooms_with_ratings(
|
||||
return result
|
||||
|
||||
|
||||
def get_predefined_amenities() -> List[str]:
|
||||
"""Get comprehensive list of predefined hotel room amenities"""
|
||||
return [
|
||||
# Basic Amenities
|
||||
"Free WiFi",
|
||||
"WiFi",
|
||||
"High-Speed Internet",
|
||||
"WiFi in Room",
|
||||
|
||||
# Entertainment
|
||||
"Flat-Screen TV",
|
||||
"TV",
|
||||
"Cable TV",
|
||||
"Satellite TV",
|
||||
"Smart TV",
|
||||
"Netflix",
|
||||
"Streaming Services",
|
||||
"DVD Player",
|
||||
"Stereo System",
|
||||
"Radio",
|
||||
"iPod Dock",
|
||||
|
||||
# Climate Control
|
||||
"Air Conditioning",
|
||||
"AC",
|
||||
"Heating",
|
||||
"Climate Control",
|
||||
"Ceiling Fan",
|
||||
"Air Purifier",
|
||||
|
||||
# Bathroom Features
|
||||
"Private Bathroom",
|
||||
"Ensuite Bathroom",
|
||||
"Bathtub",
|
||||
"Jacuzzi Bathtub",
|
||||
"Hot Tub",
|
||||
"Shower",
|
||||
"Rain Shower",
|
||||
"Walk-in Shower",
|
||||
"Bidet",
|
||||
"Hair Dryer",
|
||||
"Hairdryer",
|
||||
"Bathrobes",
|
||||
"Slippers",
|
||||
"Toiletries",
|
||||
"Premium Toiletries",
|
||||
"Towels",
|
||||
|
||||
# Food & Beverage
|
||||
"Mini Bar",
|
||||
"Minibar",
|
||||
"Refrigerator",
|
||||
"Fridge",
|
||||
"Microwave",
|
||||
"Coffee Maker",
|
||||
"Electric Kettle",
|
||||
"Tea Making Facilities",
|
||||
"Coffee Machine",
|
||||
"Nespresso Machine",
|
||||
"Kitchenette",
|
||||
"Dining Table",
|
||||
"Room Service",
|
||||
"Breakfast Included",
|
||||
"Breakfast",
|
||||
"Complimentary Water",
|
||||
"Bottled Water",
|
||||
|
||||
# Furniture & Space
|
||||
"Desk",
|
||||
"Writing Desk",
|
||||
"Office Desk",
|
||||
"Work Desk",
|
||||
"Sofa",
|
||||
"Sitting Area",
|
||||
"Lounge Area",
|
||||
"Dining Area",
|
||||
"Separate Living Area",
|
||||
"Wardrobe",
|
||||
"Closet",
|
||||
"Dresser",
|
||||
"Mirror",
|
||||
"Full-Length Mirror",
|
||||
"Seating Area",
|
||||
|
||||
# Bed & Sleep
|
||||
"King Size Bed",
|
||||
"Queen Size Bed",
|
||||
"Double Bed",
|
||||
"Twin Beds",
|
||||
"Single Bed",
|
||||
"Extra Bedding",
|
||||
"Pillow Menu",
|
||||
"Premium Bedding",
|
||||
"Blackout Curtains",
|
||||
"Soundproofing",
|
||||
|
||||
# Safety & Security
|
||||
"Safe",
|
||||
"In-Room Safe",
|
||||
"Safety Deposit Box",
|
||||
"Smoke Detector",
|
||||
"Fire Extinguisher",
|
||||
"Security System",
|
||||
"Key Card Access",
|
||||
"Door Lock",
|
||||
"Pepper Spray",
|
||||
|
||||
# Technology
|
||||
"USB Charging Ports",
|
||||
"USB Ports",
|
||||
"USB Outlets",
|
||||
"Power Outlets",
|
||||
"Charging Station",
|
||||
"Laptop Safe",
|
||||
"HDMI Port",
|
||||
"Phone",
|
||||
"Desk Phone",
|
||||
"Wake-Up Service",
|
||||
"Alarm Clock",
|
||||
"Digital Clock",
|
||||
|
||||
# View & Outdoor
|
||||
"Balcony",
|
||||
"Private Balcony",
|
||||
"Terrace",
|
||||
"Patio",
|
||||
"City View",
|
||||
"Ocean View",
|
||||
"Sea View",
|
||||
"Mountain View",
|
||||
"Garden View",
|
||||
"Pool View",
|
||||
"Park View",
|
||||
"Window",
|
||||
"Large Windows",
|
||||
"Floor-to-Ceiling Windows",
|
||||
|
||||
# Services
|
||||
"24-Hour Front Desk",
|
||||
"24 Hour Front Desk",
|
||||
"24/7 Front Desk",
|
||||
"Concierge Service",
|
||||
"Butler Service",
|
||||
"Housekeeping",
|
||||
"Daily Housekeeping",
|
||||
"Turndown Service",
|
||||
"Laundry Service",
|
||||
"Dry Cleaning",
|
||||
"Ironing Service",
|
||||
"Luggage Storage",
|
||||
"Bell Service",
|
||||
"Valet Parking",
|
||||
"Parking",
|
||||
"Free Parking",
|
||||
"Airport Shuttle",
|
||||
"Shuttle Service",
|
||||
"Car Rental",
|
||||
"Taxi Service",
|
||||
|
||||
# Fitness & Wellness
|
||||
"Gym Access",
|
||||
"Fitness Center",
|
||||
"Fitness Room",
|
||||
"Spa Access",
|
||||
"Spa",
|
||||
"Sauna",
|
||||
"Steam Room",
|
||||
"Hot Tub",
|
||||
"Massage Service",
|
||||
"Beauty Services",
|
||||
|
||||
# Recreation
|
||||
"Swimming Pool",
|
||||
"Pool",
|
||||
"Indoor Pool",
|
||||
"Outdoor Pool",
|
||||
"Infinity Pool",
|
||||
"Pool Access",
|
||||
"Golf Course",
|
||||
"Tennis Court",
|
||||
"Beach Access",
|
||||
"Water Sports",
|
||||
|
||||
# Business & Work
|
||||
"Business Center",
|
||||
"Meeting Room",
|
||||
"Conference Room",
|
||||
"Fax Service",
|
||||
"Photocopying",
|
||||
"Printing Service",
|
||||
"Secretarial Services",
|
||||
|
||||
# Accessibility
|
||||
"Wheelchair Accessible",
|
||||
"Accessible Room",
|
||||
"Elevator Access",
|
||||
"Ramp Access",
|
||||
"Accessible Bathroom",
|
||||
"Lowered Sink",
|
||||
"Grab Bars",
|
||||
"Hearing Accessible",
|
||||
"Visual Alarm",
|
||||
|
||||
# Family & Pets
|
||||
"Family Room",
|
||||
"Kids Welcome",
|
||||
"Baby Crib",
|
||||
"Extra Bed",
|
||||
"Crib",
|
||||
"Childcare Services",
|
||||
"Pets Allowed",
|
||||
"Pet Friendly",
|
||||
|
||||
# Additional Features
|
||||
"Smoking Room",
|
||||
"Non-Smoking Room",
|
||||
"No Smoking",
|
||||
"Interconnecting Rooms",
|
||||
"Adjoining Rooms",
|
||||
"Suite",
|
||||
"Separate Bedroom",
|
||||
"Kitchen",
|
||||
"Full Kitchen",
|
||||
"Dishwasher",
|
||||
"Oven",
|
||||
"Stove",
|
||||
"Washing Machine",
|
||||
"Dryer",
|
||||
"Iron",
|
||||
"Ironing Board",
|
||||
"Clothes Rack",
|
||||
"Umbrella",
|
||||
"Shoe Shine Service",
|
||||
|
||||
# Luxury Features
|
||||
"Fireplace",
|
||||
"Jacuzzi",
|
||||
"Steam Shower",
|
||||
"Spa Bath",
|
||||
"Bidet Toilet",
|
||||
"Smart Home System",
|
||||
"Lighting Control",
|
||||
"Curtain Control",
|
||||
"Automated Systems",
|
||||
"Personalized Service",
|
||||
"VIP Treatment",
|
||||
"Butler",
|
||||
"Private Entrance",
|
||||
"Private Elevator",
|
||||
"Panic Button",
|
||||
|
||||
# Entertainment & Media
|
||||
"Blu-ray Player",
|
||||
"Gaming Console",
|
||||
"PlayStation",
|
||||
"Xbox",
|
||||
"Sound System",
|
||||
"Surround Sound",
|
||||
"Music System",
|
||||
|
||||
# Special Features
|
||||
"Library",
|
||||
"Reading Room",
|
||||
"Study Room",
|
||||
"Private Pool",
|
||||
"Private Garden",
|
||||
"Yard",
|
||||
"Courtyard",
|
||||
"Outdoor Furniture",
|
||||
"BBQ Facilities",
|
||||
"Picnic Area",
|
||||
]
|
||||
|
||||
|
||||
async def get_amenities_list(db: Session) -> List[str]:
|
||||
"""Get all unique amenities from room types and rooms"""
|
||||
all_amenities = []
|
||||
"""Get all unique amenities from room types and rooms, plus predefined amenities"""
|
||||
# Start with predefined comprehensive list
|
||||
all_amenities = set(get_predefined_amenities())
|
||||
|
||||
# Get from room types
|
||||
room_types = db.query(RoomType.amenities).all()
|
||||
for rt in room_types:
|
||||
if rt.amenities:
|
||||
if isinstance(rt.amenities, list):
|
||||
all_amenities.extend([str(a).strip() for a in rt.amenities])
|
||||
all_amenities.update([str(a).strip() for a in rt.amenities if str(a).strip()])
|
||||
elif isinstance(rt.amenities, str):
|
||||
try:
|
||||
import json
|
||||
parsed = json.loads(rt.amenities)
|
||||
if isinstance(parsed, list):
|
||||
all_amenities.extend([str(a).strip() for a in parsed])
|
||||
all_amenities.update([str(a).strip() for a in parsed if str(a).strip()])
|
||||
else:
|
||||
all_amenities.extend([s.strip() for s in rt.amenities.split(',')])
|
||||
all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()])
|
||||
except:
|
||||
all_amenities.extend([s.strip() for s in rt.amenities.split(',')])
|
||||
all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()])
|
||||
|
||||
# Get from rooms
|
||||
rooms = db.query(Room.amenities).all()
|
||||
for r in rooms:
|
||||
if r.amenities:
|
||||
if isinstance(r.amenities, list):
|
||||
all_amenities.extend([str(a).strip() for a in r.amenities])
|
||||
all_amenities.update([str(a).strip() for a in r.amenities if str(a).strip()])
|
||||
elif isinstance(r.amenities, str):
|
||||
try:
|
||||
import json
|
||||
parsed = json.loads(r.amenities)
|
||||
if isinstance(parsed, list):
|
||||
all_amenities.extend([str(a).strip() for a in parsed])
|
||||
all_amenities.update([str(a).strip() for a in parsed if str(a).strip()])
|
||||
else:
|
||||
all_amenities.extend([s.strip() for s in r.amenities.split(',')])
|
||||
all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()])
|
||||
except:
|
||||
all_amenities.extend([s.strip() for s in r.amenities.split(',')])
|
||||
all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()])
|
||||
|
||||
# Return unique, non-empty values
|
||||
return sorted(list(set([a for a in all_amenities if a])))
|
||||
# Return unique, sorted values
|
||||
return sorted(list(all_amenities))
|
||||
|
||||
|
||||
409
Backend/src/services/stripe_service.py
Normal file
409
Backend/src/services/stripe_service.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Stripe payment service for processing card payments
|
||||
"""
|
||||
import stripe
|
||||
from typing import Optional, Dict, Any
|
||||
from ..config.settings import settings
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.system_settings import SystemSettings
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_stripe_secret_key(db: Session) -> Optional[str]:
|
||||
"""Get Stripe secret key from database or environment variable"""
|
||||
try:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_secret_key"
|
||||
).first()
|
||||
if setting and setting.value:
|
||||
return setting.value
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to environment variable
|
||||
return settings.STRIPE_SECRET_KEY if settings.STRIPE_SECRET_KEY else None
|
||||
|
||||
|
||||
def get_stripe_publishable_key(db: Session) -> Optional[str]:
|
||||
"""Get Stripe publishable key from database or environment variable"""
|
||||
try:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_publishable_key"
|
||||
).first()
|
||||
if setting and setting.value:
|
||||
return setting.value
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to environment variable
|
||||
return settings.STRIPE_PUBLISHABLE_KEY if settings.STRIPE_PUBLISHABLE_KEY else None
|
||||
|
||||
|
||||
def get_stripe_webhook_secret(db: Session) -> Optional[str]:
|
||||
"""Get Stripe webhook secret from database or environment variable"""
|
||||
try:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_webhook_secret"
|
||||
).first()
|
||||
if setting and setting.value:
|
||||
return setting.value
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to environment variable
|
||||
return settings.STRIPE_WEBHOOK_SECRET if settings.STRIPE_WEBHOOK_SECRET else None
|
||||
|
||||
|
||||
class StripeService:
|
||||
"""Service for handling Stripe payments"""
|
||||
|
||||
@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]:
|
||||
"""
|
||||
Create a Stripe Payment Intent
|
||||
|
||||
Args:
|
||||
amount: Payment amount in smallest currency unit (cents for USD)
|
||||
currency: Currency code (default: usd)
|
||||
metadata: Additional metadata to attach to the payment intent
|
||||
customer_id: Optional Stripe customer ID
|
||||
db: Optional database session to get keys from database
|
||||
|
||||
Returns:
|
||||
Payment intent object
|
||||
"""
|
||||
# Get secret key from database or environment
|
||||
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")
|
||||
|
||||
# Set the API key for this request
|
||||
stripe.api_key = secret_key
|
||||
|
||||
# Validate amount is reasonable (Stripe max is $999,999.99)
|
||||
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")
|
||||
|
||||
# Convert amount to cents (smallest currency unit)
|
||||
# Amount should be in dollars, so multiply by 100 to get cents
|
||||
amount_in_cents = int(round(amount * 100))
|
||||
|
||||
# Double-check the cents amount doesn't exceed Stripe's limit
|
||||
if amount_in_cents > 99999999: # $999,999.99 in cents
|
||||
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]:
|
||||
"""
|
||||
Retrieve a payment intent by ID
|
||||
|
||||
Args:
|
||||
payment_intent_id: Stripe payment intent ID
|
||||
db: Optional database session to get keys from database
|
||||
|
||||
Returns:
|
||||
Payment intent object
|
||||
"""
|
||||
# Get secret key from database or environment
|
||||
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")
|
||||
|
||||
# Set the API key for this request
|
||||
stripe.api_key = secret_key
|
||||
|
||||
try:
|
||||
intent = stripe.PaymentIntent.retrieve(payment_intent_id)
|
||||
# Safely access charges - they may not exist on all payment intents
|
||||
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, # Convert from cents
|
||||
"currency": intent.currency,
|
||||
"metadata": intent.metadata,
|
||||
"charges": charges,
|
||||
}
|
||||
except stripe.StripeError as e:
|
||||
raise ValueError(f"Stripe error: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def confirm_payment(
|
||||
payment_intent_id: str,
|
||||
db: Session,
|
||||
booking_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Confirm a payment and update database records
|
||||
|
||||
Args:
|
||||
payment_intent_id: Stripe payment intent ID
|
||||
db: Database session
|
||||
booking_id: Optional booking ID for metadata lookup
|
||||
|
||||
Returns:
|
||||
Payment record dictionary
|
||||
"""
|
||||
try:
|
||||
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
|
||||
|
||||
# Find or get booking_id from metadata
|
||||
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")
|
||||
|
||||
# Check payment intent status
|
||||
payment_status = intent_data.get("status")
|
||||
print(f"Payment intent status: {payment_status}")
|
||||
|
||||
# Accept succeeded or processing status (processing means payment is being processed)
|
||||
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.")
|
||||
|
||||
# Find existing payment or create new one
|
||||
payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.transaction_id == payment_intent_id,
|
||||
Payment.payment_method == PaymentMethod.stripe
|
||||
).first()
|
||||
|
||||
amount = intent_data["amount"]
|
||||
|
||||
if payment:
|
||||
# Update existing payment
|
||||
# Only mark as completed if payment intent succeeded
|
||||
if payment_status == "succeeded":
|
||||
payment.payment_status = PaymentStatus.completed
|
||||
payment.payment_date = datetime.utcnow()
|
||||
# If processing, keep as pending (will be updated by webhook)
|
||||
payment.amount = amount
|
||||
else:
|
||||
# Create new payment record
|
||||
payment_type = PaymentType.full
|
||||
if booking.requires_deposit and not booking.deposit_paid:
|
||||
payment_type = PaymentType.deposit
|
||||
|
||||
# Only mark as completed if payment intent succeeded
|
||||
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)
|
||||
|
||||
# Commit payment first to ensure it's saved
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
# Update booking status only if payment is completed
|
||||
if payment.payment_status == PaymentStatus.completed:
|
||||
# Refresh booking to get updated payments relationship
|
||||
db.refresh(booking)
|
||||
|
||||
if payment.payment_type == PaymentType.deposit:
|
||||
# Mark deposit as paid and confirm booking
|
||||
booking.deposit_paid = True
|
||||
if booking.status == BookingStatus.pending:
|
||||
booking.status = BookingStatus.confirmed
|
||||
elif payment.payment_type == PaymentType.full:
|
||||
# Calculate total paid from all completed payments (now includes current payment)
|
||||
total_paid = sum(
|
||||
float(p.amount) for p in booking.payments
|
||||
if p.payment_status == PaymentStatus.completed
|
||||
)
|
||||
|
||||
# Confirm booking if:
|
||||
# 1. Total paid (all payments) covers the booking price, OR
|
||||
# 2. This single payment covers the entire booking amount
|
||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||
booking.status = BookingStatus.confirmed
|
||||
|
||||
# Commit booking status update
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Safely get enum values
|
||||
def get_enum_value(enum_obj):
|
||||
"""Safely extract value from enum or return as-is"""
|
||||
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:
|
||||
print(f"AttributeError accessing payment fields: {ae}")
|
||||
print(f"Payment object: {payment}")
|
||||
print(f"Payment payment_method: {payment.payment_method if hasattr(payment, 'payment_method') else 'missing'}")
|
||||
print(f"Payment payment_type: {payment.payment_type if hasattr(payment, 'payment_type') else 'missing'}")
|
||||
print(f"Payment payment_status: {payment.payment_status if hasattr(payment, 'payment_status') else 'missing'}")
|
||||
raise
|
||||
|
||||
except ValueError as e:
|
||||
# Re-raise ValueError as-is (these are expected errors)
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
|
||||
print(f"Error in confirm_payment: {error_msg}")
|
||||
print(f"Traceback: {error_details}")
|
||||
db.rollback()
|
||||
raise ValueError(f"Error confirming payment: {error_msg}")
|
||||
|
||||
@staticmethod
|
||||
def handle_webhook(
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
db: Session
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle Stripe webhook events
|
||||
|
||||
Args:
|
||||
payload: Raw webhook payload
|
||||
signature: Stripe signature header
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Webhook event data
|
||||
"""
|
||||
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)}")
|
||||
|
||||
# Handle the event
|
||||
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:
|
||||
StripeService.confirm_payment(
|
||||
payment_intent_id=payment_intent_id,
|
||||
db=db,
|
||||
booking_id=int(booking_id)
|
||||
)
|
||||
except Exception as e:
|
||||
print(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:
|
||||
# Update payment status to failed
|
||||
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()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"event_type": event["type"],
|
||||
"event_id": event["id"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user