updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
"""add_promotion_fields_to_bookings
|
||||
|
||||
Revision ID: bd309b0742c1
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2025-11-20 02:16:09.496685
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bd309b0742c1'
|
||||
down_revision = 'f1a2b3c4d5e6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add promotion-related columns to bookings table
|
||||
op.add_column('bookings', sa.Column('original_price', sa.Numeric(10, 2), nullable=True))
|
||||
op.add_column('bookings', sa.Column('discount_amount', sa.Numeric(10, 2), nullable=True, server_default='0'))
|
||||
op.add_column('bookings', sa.Column('promotion_code', sa.String(50), nullable=True))
|
||||
# Add index on promotion_code for faster lookups
|
||||
op.create_index(op.f('ix_bookings_promotion_code'), 'bookings', ['promotion_code'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove promotion-related columns
|
||||
op.drop_index(op.f('ix_bookings_promotion_code'), table_name='bookings')
|
||||
op.drop_column('bookings', 'promotion_code')
|
||||
op.drop_column('bookings', 'discount_amount')
|
||||
op.drop_column('bookings', 'original_price')
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add_is_proforma_to_invoices
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: d9aff6c5f0d4
|
||||
Create Date: 2025-11-20 00:20:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f1a2b3c4d5e6'
|
||||
down_revision = 'd9aff6c5f0d4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add is_proforma column to invoices table
|
||||
op.add_column('invoices', sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default='0'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove is_proforma column
|
||||
op.drop_column('invoices', 'is_proforma')
|
||||
|
||||
@@ -20,6 +20,7 @@ stripe>=13.2.0
|
||||
paypal-checkout-serversdk>=1.0.3
|
||||
pyotp==2.9.0
|
||||
qrcode[pil]==7.4.2
|
||||
httpx==0.25.2
|
||||
|
||||
# Enterprise features (optional but recommended)
|
||||
# redis==5.0.1 # Uncomment if using Redis caching
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,9 @@ class Booking(Base):
|
||||
check_out_date = Column(DateTime, nullable=False)
|
||||
num_guests = Column(Integer, nullable=False, default=1)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
original_price = Column(Numeric(10, 2), nullable=True) # Price before discount
|
||||
discount_amount = Column(Numeric(10, 2), nullable=True, default=0) # Discount amount applied
|
||||
promotion_code = Column(String(50), nullable=True) # Promotion code used
|
||||
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
|
||||
deposit_paid = Column(Boolean, nullable=False, default=False)
|
||||
requires_deposit = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -37,6 +37,7 @@ class Invoice(Base):
|
||||
|
||||
# Status
|
||||
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
|
||||
is_proforma = Column(Boolean, nullable=False, default=False) # True for proforma invoices
|
||||
|
||||
# Company/Organization information (for admin to manage)
|
||||
company_name = Column(String(200), nullable=True)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,134 @@ from ..utils.email_templates import (
|
||||
router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||
|
||||
|
||||
def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> str:
|
||||
"""Generate HTML email content for invoice"""
|
||||
invoice_type = "Proforma Invoice" if is_proforma else "Invoice"
|
||||
items_html = ''.join([f'''
|
||||
<tr>
|
||||
<td>{item.get('description', 'N/A')}</td>
|
||||
<td>{item.get('quantity', 0)}</td>
|
||||
<td>{item.get('unit_price', 0):.2f}</td>
|
||||
<td>{item.get('line_total', 0):.2f}</td>
|
||||
</tr>
|
||||
''' for item in invoice.get('items', [])])
|
||||
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
|
||||
color: #0f0f0f;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}}
|
||||
.content {{
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}}
|
||||
.invoice-info {{
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.invoice-info h2 {{
|
||||
color: #d4af37;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.details {{
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.details table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.details th, .details td {{
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}}
|
||||
.details th {{
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.total {{
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{invoice_type}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="invoice-info">
|
||||
<h2>{invoice_type} #{invoice.get('invoice_number', 'N/A')}</h2>
|
||||
<p><strong>Issue Date:</strong> {invoice.get('issue_date', 'N/A')}</p>
|
||||
<p><strong>Due Date:</strong> {invoice.get('due_date', 'N/A')}</p>
|
||||
<p><strong>Status:</strong> {invoice.get('status', 'N/A')}</p>
|
||||
</div>
|
||||
|
||||
<div class="details">
|
||||
<h3>Items</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit Price</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items_html}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="total">
|
||||
<p>Subtotal: {invoice.get('subtotal', 0):.2f}</p>
|
||||
{f'<p style="color: #059669;">Discount: -{invoice.get("discount_amount", 0):.2f}</p>' if invoice.get('discount_amount', 0) > 0 else ''}
|
||||
<p>Tax: {invoice.get('tax_amount', 0):.2f}</p>
|
||||
<p><strong>Total Amount: {invoice.get('total_amount', 0):.2f}</strong></p>
|
||||
<p>Amount Paid: {invoice.get('amount_paid', 0):.2f}</p>
|
||||
<p>Balance Due: {invoice.get('balance_due', 0):.2f}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Thank you for your booking!</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def generate_booking_number() -> str:
|
||||
"""Generate unique booking number"""
|
||||
prefix = "BK"
|
||||
@@ -34,6 +162,29 @@ def generate_booking_number() -> str:
|
||||
return f"{prefix}-{ts}-{rand}"
|
||||
|
||||
|
||||
def calculate_booking_payment_balance(booking: Booking) -> dict:
|
||||
"""Calculate total paid amount and remaining balance for a booking"""
|
||||
total_paid = 0.0
|
||||
if booking.payments:
|
||||
# Sum all completed payments
|
||||
total_paid = sum(
|
||||
float(payment.amount) if payment.amount else 0.0
|
||||
for payment in booking.payments
|
||||
if payment.payment_status == PaymentStatus.completed
|
||||
)
|
||||
|
||||
total_price = float(booking.total_price) if booking.total_price else 0.0
|
||||
remaining_balance = total_price - total_paid
|
||||
|
||||
return {
|
||||
"total_paid": total_paid,
|
||||
"total_price": total_price,
|
||||
"remaining_balance": remaining_balance,
|
||||
"is_fully_paid": remaining_balance <= 0.01, # Allow small floating point differences
|
||||
"payment_percentage": (total_paid / total_price * 100) if total_price > 0 else 0
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_all_bookings(
|
||||
search: Optional[str] = Query(None),
|
||||
@@ -47,7 +198,11 @@ async def get_all_bookings(
|
||||
):
|
||||
"""Get all bookings (Admin/Staff only)"""
|
||||
try:
|
||||
query = db.query(Booking)
|
||||
query = db.query(Booking).options(
|
||||
selectinload(Booking.payments),
|
||||
joinedload(Booking.user),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
)
|
||||
|
||||
# Filter by search (booking_number)
|
||||
if search:
|
||||
@@ -79,6 +234,23 @@ async def get_all_bookings(
|
||||
# Include related data
|
||||
result = []
|
||||
for booking in bookings:
|
||||
# Determine payment_method and payment_status from payments
|
||||
payment_method_from_payments = None
|
||||
payment_status_from_payments = "unpaid"
|
||||
if booking.payments:
|
||||
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||
if isinstance(latest_payment.payment_method, PaymentMethod):
|
||||
payment_method_from_payments = latest_payment.payment_method.value
|
||||
elif hasattr(latest_payment.payment_method, 'value'):
|
||||
payment_method_from_payments = latest_payment.payment_method.value
|
||||
else:
|
||||
payment_method_from_payments = str(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"
|
||||
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
@@ -87,21 +259,33 @@ async def get_all_bookings(
|
||||
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") 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,
|
||||
"original_price": float(booking.original_price) if booking.original_price else None,
|
||||
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
|
||||
"promotion_code": booking.promotion_code,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
|
||||
"payment_status": payment_status_from_payments,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"notes": booking.special_requests, # Frontend expects notes
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
}
|
||||
|
||||
# Add user info
|
||||
if booking.user:
|
||||
booking_dict["user"] = {
|
||||
"id": booking.user.id,
|
||||
"name": booking.user.full_name,
|
||||
"full_name": booking.user.full_name,
|
||||
"email": booking.user.email,
|
||||
"phone": booking.user.phone,
|
||||
"phone_number": booking.user.phone,
|
||||
}
|
||||
|
||||
# Add room info
|
||||
@@ -111,6 +295,37 @@ async def get_all_bookings(
|
||||
"room_number": booking.room.room_number,
|
||||
"floor": booking.room.floor,
|
||||
}
|
||||
# Safely access room_type - it should be loaded via joinedload
|
||||
try:
|
||||
if hasattr(booking.room, 'room_type') and 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,
|
||||
}
|
||||
except Exception as room_type_error:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Could not load room_type for booking {booking.id}: {room_type_error}")
|
||||
|
||||
# Add payments
|
||||
if booking.payments:
|
||||
booking_dict["payments"] = [
|
||||
{
|
||||
"id": p.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.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||
"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,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
}
|
||||
for p in booking.payments
|
||||
]
|
||||
else:
|
||||
booking_dict["payments"] = []
|
||||
|
||||
result.append(booking_dict)
|
||||
|
||||
@@ -127,6 +342,11 @@ async def get_all_bookings(
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
import traceback
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in get_all_bookings: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -138,13 +358,33 @@ async def get_my_bookings(
|
||||
):
|
||||
"""Get current user's bookings"""
|
||||
try:
|
||||
bookings = db.query(Booking).filter(
|
||||
bookings = db.query(Booking).options(
|
||||
selectinload(Booking.payments),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
).filter(
|
||||
Booking.user_id == current_user.id
|
||||
).order_by(Booking.created_at.desc()).all()
|
||||
|
||||
base_url = get_base_url(request)
|
||||
result = []
|
||||
for booking in bookings:
|
||||
# Determine payment_method and payment_status from payments
|
||||
payment_method_from_payments = None
|
||||
payment_status_from_payments = "unpaid"
|
||||
if booking.payments:
|
||||
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||
if isinstance(latest_payment.payment_method, PaymentMethod):
|
||||
payment_method_from_payments = latest_payment.payment_method.value
|
||||
elif hasattr(latest_payment.payment_method, 'value'):
|
||||
payment_method_from_payments = latest_payment.payment_method.value
|
||||
else:
|
||||
payment_method_from_payments = str(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"
|
||||
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
@@ -152,12 +392,22 @@ async def get_my_bookings(
|
||||
"check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
||||
"num_guests": 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,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"guest_count": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"original_price": float(booking.original_price) if booking.original_price else None,
|
||||
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
|
||||
"promotion_code": booking.promotion_code,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
|
||||
"payment_status": payment_status_from_payments,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"notes": booking.special_requests,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
}
|
||||
|
||||
# Add room info
|
||||
@@ -184,6 +434,24 @@ async def get_my_bookings(
|
||||
}
|
||||
}
|
||||
|
||||
# Add payments
|
||||
if booking.payments:
|
||||
booking_dict["payments"] = [
|
||||
{
|
||||
"id": p.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.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||
"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,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
}
|
||||
for p in booking.payments
|
||||
]
|
||||
else:
|
||||
booking_dict["payments"] = []
|
||||
|
||||
result.append(booking_dict)
|
||||
|
||||
return {
|
||||
@@ -219,6 +487,10 @@ async def create_booking(
|
||||
guest_count = booking_data.get("guest_count", 1)
|
||||
notes = booking_data.get("notes")
|
||||
payment_method = booking_data.get("payment_method", "cash")
|
||||
promotion_code = booking_data.get("promotion_code")
|
||||
|
||||
# Invoice information (optional)
|
||||
invoice_info = booking_data.get("invoice_info", {})
|
||||
|
||||
# Detailed validation with specific error messages
|
||||
missing_fields = []
|
||||
@@ -284,6 +556,34 @@ async def create_booking(
|
||||
# Will be confirmed after successful payment
|
||||
initial_status = BookingStatus.pending
|
||||
|
||||
# Calculate original price (before discount) and discount amount
|
||||
# Calculate room price
|
||||
room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0
|
||||
number_of_nights = (check_out - check_in).days
|
||||
room_total = room_price * number_of_nights
|
||||
|
||||
# Calculate services total (will be recalculated when adding services, but estimate here)
|
||||
services = booking_data.get("services", [])
|
||||
services_total = 0.0
|
||||
if services:
|
||||
from ..models.service import Service
|
||||
for service_item in services:
|
||||
service_id = service_item.get("service_id")
|
||||
quantity = service_item.get("quantity", 1)
|
||||
if service_id:
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if service and service.is_active:
|
||||
services_total += float(service.price) * quantity
|
||||
|
||||
original_price = room_total + services_total
|
||||
discount_amount = max(0.0, original_price - float(total_price)) if promotion_code else 0.0
|
||||
|
||||
# Add promotion code to notes if provided
|
||||
final_notes = notes or ""
|
||||
if promotion_code:
|
||||
promotion_note = f"Promotion Code: {promotion_code}"
|
||||
final_notes = f"{promotion_note}\n{final_notes}".strip() if final_notes else promotion_note
|
||||
|
||||
# Create booking
|
||||
booking = Booking(
|
||||
booking_number=booking_number,
|
||||
@@ -293,7 +593,10 @@ async def create_booking(
|
||||
check_out_date=check_out,
|
||||
num_guests=guest_count,
|
||||
total_price=total_price,
|
||||
special_requests=notes,
|
||||
original_price=original_price if promotion_code else None,
|
||||
discount_amount=discount_amount if promotion_code and discount_amount > 0 else None,
|
||||
promotion_code=promotion_code,
|
||||
special_requests=final_notes,
|
||||
status=initial_status,
|
||||
requires_deposit=requires_deposit,
|
||||
deposit_paid=False,
|
||||
@@ -330,8 +633,21 @@ async def create_booking(
|
||||
logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}")
|
||||
|
||||
# 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
|
||||
# For cash payments, create a pending deposit payment record that can be paid via PayPal or Stripe
|
||||
if requires_deposit and deposit_amount > 0:
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
|
||||
deposit_payment = Payment(
|
||||
booking_id=booking.id,
|
||||
amount=deposit_amount,
|
||||
payment_method=PaymentMethod.stripe, # Default, will be updated when user chooses payment method
|
||||
payment_type=PaymentType.deposit,
|
||||
deposit_percentage=deposit_percentage,
|
||||
payment_status=PaymentStatus.pending,
|
||||
payment_date=None,
|
||||
)
|
||||
db.add(deposit_payment)
|
||||
db.flush()
|
||||
logger.info(f"Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%")
|
||||
|
||||
# Add services to booking if provided
|
||||
services = booking_data.get("services", [])
|
||||
@@ -368,9 +684,10 @@ async def create_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Automatically create invoice for the booking
|
||||
# Automatically create invoice(s) for the booking
|
||||
try:
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from ..utils.mailer import send_email
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
# Reload booking with service_usages for invoice creation
|
||||
@@ -378,15 +695,113 @@ async def create_booking(
|
||||
selectinload(Booking.service_usages).selectinload(ServiceUsage.service)
|
||||
).filter(Booking.id == booking.id).first()
|
||||
|
||||
# Create invoice automatically
|
||||
invoice = InvoiceService.create_invoice_from_booking(
|
||||
booking_id=booking.id,
|
||||
db=db,
|
||||
created_by_id=current_user.id,
|
||||
tax_rate=0.0, # Default no tax, can be configured
|
||||
discount_amount=0.0,
|
||||
due_days=30,
|
||||
)
|
||||
# Get company settings for invoice
|
||||
from ..models.system_settings import SystemSettings
|
||||
company_settings = {}
|
||||
for key in ["company_name", "company_address", "company_phone", "company_email", "company_tax_id", "company_logo_url"]:
|
||||
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||
if setting and setting.value:
|
||||
company_settings[key] = setting.value
|
||||
|
||||
# Get tax rate from settings (default to 0 if not set)
|
||||
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == "tax_rate").first()
|
||||
tax_rate = float(tax_rate_setting.value) if tax_rate_setting and tax_rate_setting.value else 0.0
|
||||
|
||||
# Merge invoice info from form with company settings (form takes precedence)
|
||||
# Only include non-empty values from invoice_info
|
||||
invoice_kwargs = {**company_settings}
|
||||
if invoice_info:
|
||||
if invoice_info.get("company_name"):
|
||||
invoice_kwargs["company_name"] = invoice_info.get("company_name")
|
||||
if invoice_info.get("company_address"):
|
||||
invoice_kwargs["company_address"] = invoice_info.get("company_address")
|
||||
if invoice_info.get("company_tax_id"):
|
||||
invoice_kwargs["company_tax_id"] = invoice_info.get("company_tax_id")
|
||||
if invoice_info.get("customer_tax_id"):
|
||||
invoice_kwargs["customer_tax_id"] = invoice_info.get("customer_tax_id")
|
||||
if invoice_info.get("notes"):
|
||||
invoice_kwargs["notes"] = invoice_info.get("notes")
|
||||
if invoice_info.get("terms_and_conditions"):
|
||||
invoice_kwargs["terms_and_conditions"] = invoice_info.get("terms_and_conditions")
|
||||
if invoice_info.get("payment_instructions"):
|
||||
invoice_kwargs["payment_instructions"] = invoice_info.get("payment_instructions")
|
||||
|
||||
# Get discount from booking
|
||||
booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0
|
||||
|
||||
# Create invoices based on payment method
|
||||
if payment_method == "cash":
|
||||
# For cash bookings: create invoice for 20% deposit + proforma for 80% remaining
|
||||
deposit_amount = float(total_price) * 0.2
|
||||
remaining_amount = float(total_price) * 0.8
|
||||
|
||||
# Create invoice for deposit (20%)
|
||||
deposit_invoice = InvoiceService.create_invoice_from_booking(
|
||||
booking_id=booking.id,
|
||||
db=db,
|
||||
created_by_id=current_user.id,
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=booking_discount,
|
||||
due_days=30,
|
||||
is_proforma=False,
|
||||
invoice_amount=deposit_amount,
|
||||
**invoice_kwargs
|
||||
)
|
||||
|
||||
# Create proforma invoice for remaining amount (80%)
|
||||
proforma_invoice = InvoiceService.create_invoice_from_booking(
|
||||
booking_id=booking.id,
|
||||
db=db,
|
||||
created_by_id=current_user.id,
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=booking_discount,
|
||||
due_days=30,
|
||||
is_proforma=True,
|
||||
invoice_amount=remaining_amount,
|
||||
**invoice_kwargs
|
||||
)
|
||||
|
||||
# Send deposit invoice via email
|
||||
try:
|
||||
invoice_html = _generate_invoice_email_html(deposit_invoice, is_proforma=False)
|
||||
await send_email(
|
||||
to=current_user.email,
|
||||
subject=f"Invoice {deposit_invoice['invoice_number']} - Deposit Payment",
|
||||
html=invoice_html
|
||||
)
|
||||
logger.info(f"Deposit invoice sent to {current_user.email}")
|
||||
except Exception as email_error:
|
||||
logger.error(f"Failed to send deposit invoice email: {str(email_error)}")
|
||||
|
||||
# Send proforma invoice via email
|
||||
try:
|
||||
proforma_html = _generate_invoice_email_html(proforma_invoice, is_proforma=True)
|
||||
await send_email(
|
||||
to=current_user.email,
|
||||
subject=f"Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance",
|
||||
html=proforma_html
|
||||
)
|
||||
logger.info(f"Proforma invoice sent to {current_user.email}")
|
||||
except Exception as email_error:
|
||||
logger.error(f"Failed to send proforma invoice email: {str(email_error)}")
|
||||
else:
|
||||
# For full payment (Stripe/PayPal): create full invoice
|
||||
# Invoice will be created and sent after payment is confirmed
|
||||
# We create it now as draft, and it will be updated when payment is confirmed
|
||||
full_invoice = InvoiceService.create_invoice_from_booking(
|
||||
booking_id=booking.id,
|
||||
db=db,
|
||||
created_by_id=current_user.id,
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=booking_discount,
|
||||
due_days=30,
|
||||
is_proforma=False,
|
||||
**invoice_kwargs
|
||||
)
|
||||
|
||||
# Don't send invoice email yet - will be sent after payment is confirmed
|
||||
# The invoice will be updated and sent when payment is completed
|
||||
logger.info(f"Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)")
|
||||
except Exception as e:
|
||||
# Log error but don't fail booking creation if invoice creation fails
|
||||
import logging
|
||||
@@ -511,32 +926,7 @@ async def create_booking(
|
||||
"capacity": booking.room.room_type.capacity,
|
||||
}
|
||||
|
||||
# Send booking confirmation email (non-blocking)
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||
|
||||
email_html = booking_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=current_user.full_name,
|
||||
room_number=room.room_number if room else "N/A",
|
||||
room_type=room_type_name,
|
||||
check_in=check_in.strftime("%B %d, %Y"),
|
||||
check_out=check_out.strftime("%B %d, %Y"),
|
||||
num_guests=guest_count,
|
||||
total_price=float(total_price),
|
||||
requires_deposit=requires_deposit,
|
||||
deposit_amount=deposit_amount if requires_deposit else None,
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=current_user.email,
|
||||
subject=f"Booking Confirmation - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send booking confirmation email: {e}")
|
||||
# Don't send email here - emails will be sent when booking is confirmed or cancelled
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -678,7 +1068,11 @@ async def get_booking_by_id(
|
||||
"id": p.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.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||
"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,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
}
|
||||
for p in booking.payments
|
||||
]
|
||||
@@ -757,7 +1151,12 @@ async def cancel_booking(
|
||||
|
||||
# Send cancellation email (non-blocking)
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
email_html = booking_status_changed_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
@@ -770,7 +1169,9 @@ async def cancel_booking(
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send cancellation email: {e}")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send cancellation email: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -792,7 +1193,10 @@ async def update_booking(
|
||||
):
|
||||
"""Update booking status (Admin only)"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
||||
# Load booking with payments to check balance
|
||||
booking = db.query(Booking).options(
|
||||
selectinload(Booking.payments)
|
||||
).filter(Booking.id == id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
@@ -807,29 +1211,105 @@ async def update_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Send status change email if status changed (non-blocking)
|
||||
if status_value and old_status != booking.status:
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
email_html = booking_status_changed_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
status=booking.status.value,
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email if booking.user else None,
|
||||
subject=f"Booking Status Updated - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send status change email: {e}")
|
||||
# Check payment balance if status changed to checked_in
|
||||
payment_warning = None
|
||||
if status_value and old_status != booking.status and booking.status == BookingStatus.checked_in:
|
||||
payment_balance = calculate_booking_payment_balance(booking)
|
||||
if payment_balance["remaining_balance"] > 0.01: # More than 1 cent remaining
|
||||
payment_warning = {
|
||||
"message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
|
||||
"total_paid": payment_balance["total_paid"],
|
||||
"total_price": payment_balance["total_price"],
|
||||
"remaining_balance": payment_balance["remaining_balance"],
|
||||
"payment_percentage": payment_balance["payment_percentage"]
|
||||
}
|
||||
|
||||
return {
|
||||
# Send status change email only if status changed to confirmed or cancelled (non-blocking)
|
||||
if status_value and old_status != booking.status:
|
||||
if booking.status in [BookingStatus.confirmed, BookingStatus.cancelled]:
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
from ..services.room_service import get_base_url
|
||||
from fastapi import Request
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
if booking.status == BookingStatus.confirmed:
|
||||
# Send booking confirmation email with full details
|
||||
from sqlalchemy.orm import selectinload
|
||||
booking_with_room = db.query(Booking).options(
|
||||
selectinload(Booking.room).selectinload(Room.room_type)
|
||||
).filter(Booking.id == booking.id).first()
|
||||
|
||||
room = booking_with_room.room if booking_with_room else None
|
||||
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
email_html = booking_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
room_number=room.room_number if room else "N/A",
|
||||
room_type=room_type_name,
|
||||
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
|
||||
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
|
||||
num_guests=booking.num_guests,
|
||||
total_price=float(booking.total_price),
|
||||
requires_deposit=booking.requires_deposit,
|
||||
deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None,
|
||||
original_price=float(booking.original_price) if booking.original_price else None,
|
||||
discount_amount=float(booking.discount_amount) if booking.discount_amount else None,
|
||||
promotion_code=booking.promotion_code,
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email if booking.user else None,
|
||||
subject=f"Booking Confirmed - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
elif booking.status == BookingStatus.cancelled:
|
||||
# Send cancellation email
|
||||
email_html = booking_status_changed_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
status="cancelled",
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email if booking.user else None,
|
||||
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send status change email: {e}")
|
||||
|
||||
response_data = {
|
||||
"status": "success",
|
||||
"message": "Booking updated successfully",
|
||||
"data": {"booking": booking}
|
||||
}
|
||||
|
||||
# Add payment warning if there's remaining balance during check-in
|
||||
if payment_warning:
|
||||
response_data["warning"] = payment_warning
|
||||
response_data["message"] = "Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance."
|
||||
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -844,30 +1324,126 @@ async def check_booking_by_number(
|
||||
):
|
||||
"""Check booking by booking number"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.booking_number == booking_number).first()
|
||||
booking = db.query(Booking).options(
|
||||
selectinload(Booking.payments),
|
||||
joinedload(Booking.user),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
).filter(Booking.booking_number == booking_number).first()
|
||||
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
# Determine payment_method and payment_status from payments
|
||||
payment_method_from_payments = None
|
||||
payment_status_from_payments = "unpaid"
|
||||
if booking.payments:
|
||||
latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min)
|
||||
if isinstance(latest_payment.payment_method, PaymentMethod):
|
||||
payment_method_from_payments = latest_payment.payment_method.value
|
||||
elif hasattr(latest_payment.payment_method, 'value'):
|
||||
payment_method_from_payments = latest_payment.payment_method.value
|
||||
else:
|
||||
payment_method_from_payments = str(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"
|
||||
|
||||
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.strftime("%Y-%m-%d") if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None,
|
||||
"num_guests": booking.num_guests,
|
||||
"guest_count": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"original_price": float(booking.original_price) if booking.original_price else None,
|
||||
"discount_amount": float(booking.discount_amount) if booking.discount_amount else None,
|
||||
"promotion_code": booking.promotion_code,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"payment_method": payment_method_from_payments if payment_method_from_payments else "cash",
|
||||
"payment_status": payment_status_from_payments,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"notes": booking.special_requests,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"createdAt": booking.created_at.isoformat() if booking.created_at else None,
|
||||
"updated_at": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
"updatedAt": booking.updated_at.isoformat() if booking.updated_at else None,
|
||||
}
|
||||
|
||||
# Add user info
|
||||
if booking.user:
|
||||
booking_dict["user"] = {
|
||||
"id": booking.user.id,
|
||||
"name": booking.user.full_name,
|
||||
"full_name": booking.user.full_name,
|
||||
"email": booking.user.email,
|
||||
"phone": booking.user.phone,
|
||||
"phone_number": booking.user.phone,
|
||||
}
|
||||
|
||||
# Add room info
|
||||
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,
|
||||
}
|
||||
|
||||
return {
|
||||
# Add payments
|
||||
if booking.payments:
|
||||
booking_dict["payments"] = [
|
||||
{
|
||||
"id": p.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.value if hasattr(p.payment_method, 'value') else str(p.payment_method)),
|
||||
"payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)),
|
||||
"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,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
}
|
||||
for p in booking.payments
|
||||
]
|
||||
else:
|
||||
booking_dict["payments"] = []
|
||||
|
||||
# Calculate and add payment balance information
|
||||
payment_balance = calculate_booking_payment_balance(booking)
|
||||
booking_dict["payment_balance"] = {
|
||||
"total_paid": payment_balance["total_paid"],
|
||||
"total_price": payment_balance["total_price"],
|
||||
"remaining_balance": payment_balance["remaining_balance"],
|
||||
"is_fully_paid": payment_balance["is_fully_paid"],
|
||||
"payment_percentage": payment_balance["payment_percentage"]
|
||||
}
|
||||
|
||||
# Add warning if there's remaining balance (useful for check-in)
|
||||
response_data = {
|
||||
"status": "success",
|
||||
"data": {"booking": booking_dict}
|
||||
}
|
||||
|
||||
if payment_balance["remaining_balance"] > 0.01:
|
||||
response_data["warning"] = {
|
||||
"message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}",
|
||||
"remaining_balance": payment_balance["remaining_balance"],
|
||||
"payment_percentage": payment_balance["payment_percentage"]
|
||||
}
|
||||
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -25,7 +25,15 @@ class ContactForm(BaseModel):
|
||||
|
||||
def get_admin_email(db: Session) -> str:
|
||||
"""Get admin email from system settings or find admin user"""
|
||||
# First, try to get from system settings
|
||||
# First, try to get from company_email (company settings)
|
||||
company_email_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "company_email"
|
||||
).first()
|
||||
|
||||
if company_email_setting and company_email_setting.value:
|
||||
return company_email_setting.value
|
||||
|
||||
# Second, try to get from admin_email (legacy setting)
|
||||
admin_email_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "admin_email"
|
||||
).first()
|
||||
@@ -52,7 +60,7 @@ def get_admin_email(db: Session) -> str:
|
||||
# Last resort: raise error
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Admin email not configured. Please set admin_email in system settings or ensure an admin user exists."
|
||||
detail="Admin email not configured. Please set company_email in system settings or ensure an admin user exists."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
@@ -11,13 +11,51 @@ from ..models.user import User
|
||||
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 ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
|
||||
from ..services.stripe_service import StripeService
|
||||
from ..services.paypal_service import PayPalService
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
|
||||
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str = "Payment failed or canceled"):
|
||||
"""
|
||||
Helper function to cancel a booking when payment fails or is canceled.
|
||||
This bypasses the normal cancellation restrictions and sends cancellation email.
|
||||
"""
|
||||
if booking.status == BookingStatus.cancelled:
|
||||
return # Already cancelled
|
||||
|
||||
booking.status = BookingStatus.cancelled
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Send cancellation email (non-blocking)
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
if booking.user:
|
||||
email_html = booking_status_changed_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
status="cancelled",
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send cancellation email: {e}")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_payments(
|
||||
booking_id: Optional[int] = Query(None),
|
||||
@@ -29,11 +67,11 @@ async def get_payments(
|
||||
):
|
||||
"""Get all payments"""
|
||||
try:
|
||||
query = db.query(Payment)
|
||||
|
||||
# Filter by booking_id
|
||||
# Build base query
|
||||
if booking_id:
|
||||
query = query.filter(Payment.booking_id == booking_id)
|
||||
query = db.query(Payment).filter(Payment.booking_id == booking_id)
|
||||
else:
|
||||
query = db.query(Payment)
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
@@ -46,7 +84,14 @@ async def get_payments(
|
||||
if current_user.role_id != 1: # Not admin
|
||||
query = query.join(Booking).filter(Booking.user_id == current_user.id)
|
||||
|
||||
# Get total count before applying eager loading
|
||||
total = query.count()
|
||||
|
||||
# Load payments with booking and user relationships using selectinload to avoid join conflicts
|
||||
query = query.options(
|
||||
selectinload(Payment.booking).selectinload(Booking.user)
|
||||
)
|
||||
|
||||
offset = (page - 1) * limit
|
||||
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
@@ -72,6 +117,14 @@ async def get_payments(
|
||||
"id": payment.booking.id,
|
||||
"booking_number": payment.booking.booking_number,
|
||||
}
|
||||
# Include user information if available
|
||||
if payment.booking.user:
|
||||
payment_dict["booking"]["user"] = {
|
||||
"id": payment.booking.user.id,
|
||||
"name": payment.booking.user.full_name,
|
||||
"full_name": payment.booking.user.full_name,
|
||||
"email": payment.booking.user.email,
|
||||
}
|
||||
|
||||
result.append(payment_dict)
|
||||
|
||||
@@ -87,8 +140,13 @@ async def get_payments(
|
||||
},
|
||||
},
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error fetching payments: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching payments: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/booking/{booking_id}")
|
||||
@@ -108,8 +166,10 @@ async def get_payments_by_booking_id(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Get all payments for this booking
|
||||
payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
||||
# Get all payments for this booking with user relationship
|
||||
payments = db.query(Payment).options(
|
||||
joinedload(Payment.booking).joinedload(Booking.user)
|
||||
).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for payment in payments:
|
||||
@@ -133,6 +193,14 @@ async def get_payments_by_booking_id(
|
||||
"id": payment.booking.id,
|
||||
"booking_number": payment.booking.booking_number,
|
||||
}
|
||||
# Include user information if available
|
||||
if payment.booking.user:
|
||||
payment_dict["booking"]["user"] = {
|
||||
"id": payment.booking.user.id,
|
||||
"name": payment.booking.user.full_name,
|
||||
"full_name": payment.booking.user.full_name,
|
||||
"email": payment.booking.user.email,
|
||||
}
|
||||
|
||||
result.append(payment_dict)
|
||||
|
||||
@@ -241,14 +309,34 @@ async def create_payment(
|
||||
# Send payment confirmation email if payment was marked as paid (non-blocking)
|
||||
if payment.payment_status == PaymentStatus.completed and booking.user:
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
email_html = payment_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name,
|
||||
amount=float(payment.amount),
|
||||
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
||||
transaction_id=payment.transaction_id,
|
||||
client_url=client_url
|
||||
payment_type=payment.payment_type.value if payment.payment_type else None,
|
||||
total_price=float(booking.total_price),
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
@@ -256,7 +344,9 @@ async def create_payment(
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send payment confirmation email: {e}")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send payment confirmation email: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -284,16 +374,28 @@ async def update_payment_status(
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
|
||||
status_value = status_data.get("status")
|
||||
old_status = payment.payment_status
|
||||
|
||||
if status_value:
|
||||
try:
|
||||
payment.payment_status = PaymentStatus(status_value)
|
||||
new_status = PaymentStatus(status_value)
|
||||
payment.payment_status = new_status
|
||||
|
||||
# Auto-cancel booking if payment is marked as failed or refunded
|
||||
if new_status in [PaymentStatus.failed, PaymentStatus.refunded]:
|
||||
booking = db.query(Booking).filter(Booking.id == payment.booking_id).first()
|
||||
if booking and booking.status != BookingStatus.cancelled:
|
||||
await cancel_booking_on_payment_failure(
|
||||
booking,
|
||||
db,
|
||||
reason=f"Payment {new_status.value}"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid payment status")
|
||||
|
||||
if status_data.get("transaction_id"):
|
||||
payment.transaction_id = status_data["transaction_id"]
|
||||
|
||||
old_status = payment.payment_status
|
||||
if status_data.get("mark_as_paid"):
|
||||
payment.payment_status = PaymentStatus.completed
|
||||
payment.payment_date = datetime.utcnow()
|
||||
@@ -304,17 +406,50 @@ async def update_payment_status(
|
||||
# Send payment confirmation email if payment was just completed (non-blocking)
|
||||
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
# Refresh booking relationship
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
if payment.booking and payment.booking.user:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
email_html = payment_confirmation_email_template(
|
||||
booking_number=payment.booking.booking_number,
|
||||
guest_name=payment.booking.user.full_name,
|
||||
amount=float(payment.amount),
|
||||
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
|
||||
transaction_id=payment.transaction_id,
|
||||
client_url=client_url
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
await send_email(
|
||||
to=payment.booking.user.email,
|
||||
@@ -325,10 +460,22 @@ async def update_payment_status(
|
||||
# If this is a deposit payment, update booking deposit_paid status
|
||||
if payment.payment_type == PaymentType.deposit and payment.booking:
|
||||
payment.booking.deposit_paid = True
|
||||
# Optionally auto-confirm booking if deposit is paid
|
||||
if payment.booking.status == BookingStatus.pending:
|
||||
# Restore cancelled bookings or confirm pending bookings when deposit is paid
|
||||
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
payment.booking.status = BookingStatus.confirmed
|
||||
db.commit()
|
||||
# If this is a full payment, also restore cancelled bookings
|
||||
elif payment.payment_type == PaymentType.full and payment.booking:
|
||||
# Calculate total paid from all completed payments
|
||||
total_paid = sum(
|
||||
float(p.amount) for p in payment.booking.payments
|
||||
if p.payment_status == PaymentStatus.completed
|
||||
)
|
||||
# Confirm booking if fully paid, and restore cancelled bookings
|
||||
if total_paid >= float(payment.booking.total_price):
|
||||
if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
payment.booking.status = BookingStatus.confirmed
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"Failed to send payment confirmation email: {e}")
|
||||
|
||||
@@ -395,6 +542,29 @@ async def create_stripe_payment_intent(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# For deposit payments, verify the amount matches the deposit payment record
|
||||
# This ensures users are only charged the deposit (20%) and not the full amount
|
||||
if booking.requires_deposit and not booking.deposit_paid:
|
||||
deposit_payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.payment_type == PaymentType.deposit,
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
if deposit_payment:
|
||||
expected_deposit_amount = float(deposit_payment.amount)
|
||||
# Allow small floating point differences (0.01)
|
||||
if abs(amount - expected_deposit_amount) > 0.01:
|
||||
logger.warning(
|
||||
f"Amount mismatch for deposit payment: "
|
||||
f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, "
|
||||
f"Booking total ${float(booking.total_price):,.2f}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})."
|
||||
)
|
||||
|
||||
# Create payment intent
|
||||
intent = StripeService.create_payment_intent(
|
||||
amount=amount,
|
||||
@@ -472,7 +642,7 @@ async def confirm_stripe_payment(
|
||||
)
|
||||
|
||||
# Confirm payment (this commits the transaction internally)
|
||||
payment = StripeService.confirm_payment(
|
||||
payment = await StripeService.confirm_payment(
|
||||
payment_intent_id=payment_intent_id,
|
||||
db=db,
|
||||
booking_id=booking_id
|
||||
@@ -495,14 +665,34 @@ async def confirm_stripe_payment(
|
||||
# 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")
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
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
|
||||
payment_type=payment.get("payment_type"),
|
||||
total_price=float(booking.total_price),
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
@@ -574,7 +764,7 @@ async def stripe_webhook(
|
||||
detail="Missing stripe-signature header"
|
||||
)
|
||||
|
||||
result = StripeService.handle_webhook(
|
||||
result = await StripeService.handle_webhook(
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
db=db
|
||||
@@ -640,6 +830,31 @@ async def create_paypal_order(
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# For deposit payments, verify the amount matches the deposit payment record
|
||||
# This ensures users are only charged the deposit (20%) and not the full amount
|
||||
if booking.requires_deposit and not booking.deposit_paid:
|
||||
deposit_payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.payment_type == PaymentType.deposit,
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
if deposit_payment:
|
||||
expected_deposit_amount = float(deposit_payment.amount)
|
||||
# Allow small floating point differences (0.01)
|
||||
if abs(amount - expected_deposit_amount) > 0.01:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"Amount mismatch for deposit payment: "
|
||||
f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, "
|
||||
f"Booking total ${float(booking.total_price):,.2f}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"For pay-on-arrival bookings, only the deposit amount (${expected_deposit_amount:,.2f}) should be charged, not the full booking amount (${float(booking.total_price):,.2f})."
|
||||
)
|
||||
|
||||
# Get return URLs from request or use defaults
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return")
|
||||
@@ -689,6 +904,63 @@ async def create_paypal_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/paypal/cancel")
|
||||
async def cancel_paypal_payment(
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark PayPal payment as failed and cancel booking when user cancels on PayPal"""
|
||||
try:
|
||||
booking_id = payment_data.get("booking_id")
|
||||
|
||||
if not booking_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="booking_id is required"
|
||||
)
|
||||
|
||||
# Find pending PayPal payment for this booking
|
||||
payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.payment_method == PaymentMethod.paypal,
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
# Also check for deposit payments
|
||||
if not payment:
|
||||
payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.payment_type == PaymentType.deposit,
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
if payment:
|
||||
payment.payment_status = PaymentStatus.failed
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
# Auto-cancel booking
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if booking and booking.status != BookingStatus.cancelled:
|
||||
await cancel_booking_on_payment_failure(
|
||||
booking,
|
||||
db,
|
||||
reason="PayPal payment canceled by user"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Payment canceled and booking cancelled"
|
||||
}
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/paypal/capture")
|
||||
async def capture_paypal_payment(
|
||||
payment_data: dict,
|
||||
@@ -707,7 +979,7 @@ async def capture_paypal_payment(
|
||||
)
|
||||
|
||||
# Confirm payment (this commits the transaction internally)
|
||||
payment = PayPalService.confirm_payment(
|
||||
payment = await PayPalService.confirm_payment(
|
||||
order_id=order_id,
|
||||
db=db,
|
||||
booking_id=booking_id
|
||||
@@ -727,14 +999,34 @@ async def capture_paypal_payment(
|
||||
# Send payment confirmation email (non-blocking)
|
||||
if booking and booking.user:
|
||||
try:
|
||||
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
email_html = payment_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name,
|
||||
amount=payment["amount"],
|
||||
payment_method="paypal",
|
||||
transaction_id=payment["transaction_id"],
|
||||
client_url=client_url
|
||||
payment_type=payment.get("payment_type"),
|
||||
total_price=float(booking.total_price),
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
|
||||
@@ -128,7 +128,8 @@ async def validate_promotion(
|
||||
"""Validate and apply promotion"""
|
||||
try:
|
||||
code = validation_data.get("code")
|
||||
booking_amount = float(validation_data.get("booking_amount", 0))
|
||||
# Accept both booking_value (from frontend) and booking_amount (for backward compatibility)
|
||||
booking_amount = float(validation_data.get("booking_value") or validation_data.get("booking_amount", 0))
|
||||
|
||||
promotion = db.query(Promotion).filter(Promotion.code == code).first()
|
||||
if not promotion:
|
||||
@@ -161,17 +162,30 @@ async def validate_promotion(
|
||||
final_amount = booking_amount - discount_amount
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"data": {
|
||||
"promotion": {
|
||||
"id": promotion.id,
|
||||
"code": promotion.code,
|
||||
"name": promotion.name,
|
||||
"description": promotion.description,
|
||||
"discount_type": promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type),
|
||||
"discount_value": float(promotion.discount_value) if promotion.discount_value else 0,
|
||||
"min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None,
|
||||
"max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None,
|
||||
"start_date": promotion.start_date.isoformat() if promotion.start_date else None,
|
||||
"end_date": promotion.end_date.isoformat() if promotion.end_date else None,
|
||||
"usage_limit": promotion.usage_limit,
|
||||
"used_count": promotion.used_count,
|
||||
"status": "active" if promotion.is_active else "inactive",
|
||||
},
|
||||
"discount": discount_amount,
|
||||
"original_amount": booking_amount,
|
||||
"discount_amount": discount_amount,
|
||||
"final_amount": final_amount,
|
||||
}
|
||||
},
|
||||
"message": "Promotion validated successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -895,6 +895,7 @@ class UpdateCompanySettingsRequest(BaseModel):
|
||||
company_phone: Optional[str] = None
|
||||
company_email: Optional[str] = None
|
||||
company_address: Optional[str] = None
|
||||
tax_rate: Optional[float] = None
|
||||
|
||||
|
||||
@router.get("/company")
|
||||
@@ -911,6 +912,7 @@ async def get_company_settings(
|
||||
"company_phone",
|
||||
"company_email",
|
||||
"company_address",
|
||||
"tax_rate",
|
||||
]
|
||||
|
||||
settings_dict = {}
|
||||
@@ -944,6 +946,7 @@ async def get_company_settings(
|
||||
"company_phone": settings_dict.get("company_phone", ""),
|
||||
"company_email": settings_dict.get("company_email", ""),
|
||||
"company_address": settings_dict.get("company_address", ""),
|
||||
"tax_rate": float(settings_dict.get("tax_rate", 0)) if settings_dict.get("tax_rate") else 0.0,
|
||||
"updated_at": updated_at,
|
||||
"updated_by": updated_by,
|
||||
}
|
||||
@@ -972,6 +975,8 @@ async def update_company_settings(
|
||||
db_settings["company_email"] = request_data.company_email
|
||||
if request_data.company_address is not None:
|
||||
db_settings["company_address"] = request_data.company_address
|
||||
if request_data.tax_rate is not None:
|
||||
db_settings["tax_rate"] = str(request_data.tax_rate)
|
||||
|
||||
for key, value in db_settings.items():
|
||||
# Find or create setting
|
||||
@@ -997,7 +1002,7 @@ async def update_company_settings(
|
||||
|
||||
# Get updated settings
|
||||
updated_settings = {}
|
||||
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]:
|
||||
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate"]:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == key
|
||||
).first()
|
||||
@@ -1032,6 +1037,7 @@ async def update_company_settings(
|
||||
"company_phone": updated_settings.get("company_phone", ""),
|
||||
"company_email": updated_settings.get("company_email", ""),
|
||||
"company_address": updated_settings.get("company_address", ""),
|
||||
"tax_rate": float(updated_settings.get("tax_rate", 0)) if updated_settings.get("tax_rate") else 0.0,
|
||||
"updated_at": updated_at,
|
||||
"updated_by": updated_by,
|
||||
}
|
||||
@@ -1243,3 +1249,272 @@ async def upload_company_favicon(
|
||||
logger.error(f"Error uploading favicon: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/recaptcha")
|
||||
async def get_recaptcha_settings(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get reCAPTCHA settings (Public endpoint for frontend)"""
|
||||
try:
|
||||
site_key_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_site_key"
|
||||
).first()
|
||||
|
||||
enabled_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_enabled"
|
||||
).first()
|
||||
|
||||
result = {
|
||||
"recaptcha_site_key": "",
|
||||
"recaptcha_enabled": False,
|
||||
}
|
||||
|
||||
if site_key_setting:
|
||||
result["recaptcha_site_key"] = site_key_setting.value or ""
|
||||
|
||||
if enabled_setting:
|
||||
result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/recaptcha/admin")
|
||||
async def get_recaptcha_settings_admin(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get reCAPTCHA settings (Admin only - includes secret key)"""
|
||||
try:
|
||||
site_key_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_site_key"
|
||||
).first()
|
||||
|
||||
secret_key_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_secret_key"
|
||||
).first()
|
||||
|
||||
enabled_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_enabled"
|
||||
).first()
|
||||
|
||||
# Mask secret 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 = {
|
||||
"recaptcha_site_key": "",
|
||||
"recaptcha_secret_key": "",
|
||||
"recaptcha_secret_key_masked": "",
|
||||
"recaptcha_enabled": False,
|
||||
"has_site_key": False,
|
||||
"has_secret_key": False,
|
||||
}
|
||||
|
||||
if site_key_setting:
|
||||
result["recaptcha_site_key"] = site_key_setting.value or ""
|
||||
result["has_site_key"] = bool(site_key_setting.value)
|
||||
result["updated_at"] = site_key_setting.updated_at.isoformat() if site_key_setting.updated_at else None
|
||||
result["updated_by"] = site_key_setting.updated_by.full_name if site_key_setting.updated_by else None
|
||||
|
||||
if secret_key_setting:
|
||||
result["recaptcha_secret_key"] = secret_key_setting.value or ""
|
||||
result["recaptcha_secret_key_masked"] = mask_key(secret_key_setting.value or "")
|
||||
result["has_secret_key"] = bool(secret_key_setting.value)
|
||||
|
||||
if enabled_setting:
|
||||
result["recaptcha_enabled"] = enabled_setting.value.lower() == "true" if enabled_setting.value else False
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/recaptcha")
|
||||
async def update_recaptcha_settings(
|
||||
recaptcha_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update reCAPTCHA settings (Admin only)"""
|
||||
try:
|
||||
site_key = recaptcha_data.get("recaptcha_site_key", "").strip()
|
||||
secret_key = recaptcha_data.get("recaptcha_secret_key", "").strip()
|
||||
enabled = recaptcha_data.get("recaptcha_enabled", False)
|
||||
|
||||
# Update or create site key setting
|
||||
if site_key:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_site_key"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = site_key
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="recaptcha_site_key",
|
||||
value=site_key,
|
||||
description="Google reCAPTCHA site key for frontend",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
# Update or create secret key setting
|
||||
if secret_key:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_secret_key"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = secret_key
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="recaptcha_secret_key",
|
||||
value=secret_key,
|
||||
description="Google reCAPTCHA secret key for backend verification",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
# Update or create enabled setting
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_enabled"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = str(enabled).lower()
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="recaptcha_enabled",
|
||||
value=str(enabled).lower(),
|
||||
description="Enable or disable reCAPTCHA verification",
|
||||
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": "reCAPTCHA settings updated successfully",
|
||||
"data": {
|
||||
"recaptcha_site_key": site_key if site_key else "",
|
||||
"recaptcha_secret_key": secret_key if secret_key else "",
|
||||
"recaptcha_secret_key_masked": mask_key(secret_key) if secret_key else "",
|
||||
"recaptcha_enabled": enabled,
|
||||
"has_site_key": bool(site_key),
|
||||
"has_secret_key": bool(secret_key),
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/recaptcha/verify")
|
||||
async def verify_recaptcha(
|
||||
verification_data: dict,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify reCAPTCHA token (Public endpoint)"""
|
||||
try:
|
||||
token = verification_data.get("token", "").strip()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="reCAPTCHA token is required"
|
||||
)
|
||||
|
||||
# Get reCAPTCHA settings
|
||||
enabled_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_enabled"
|
||||
).first()
|
||||
|
||||
secret_key_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "recaptcha_secret_key"
|
||||
).first()
|
||||
|
||||
# Check if reCAPTCHA is enabled
|
||||
is_enabled = False
|
||||
if enabled_setting:
|
||||
is_enabled = enabled_setting.value.lower() == "true" if enabled_setting.value else False
|
||||
|
||||
if not is_enabled:
|
||||
# If disabled, always return success
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"verified": True,
|
||||
"message": "reCAPTCHA is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
if not secret_key_setting or not secret_key_setting.value:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="reCAPTCHA secret key is not configured"
|
||||
)
|
||||
|
||||
# Verify with Google reCAPTCHA API
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
data={
|
||||
"secret": secret_key_setting.value,
|
||||
"response": token
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"verified": True,
|
||||
"score": result.get("score"), # For v3
|
||||
"action": result.get("action") # For v3
|
||||
}
|
||||
}
|
||||
else:
|
||||
error_codes = result.get("error-codes", [])
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"verified": False,
|
||||
"error_codes": error_codes
|
||||
}
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=408,
|
||||
detail="reCAPTCHA verification timeout"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying reCAPTCHA: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,14 +11,15 @@ from ..models.payment import Payment, PaymentStatus
|
||||
from ..models.user import User
|
||||
|
||||
|
||||
def generate_invoice_number(db: Session) -> str:
|
||||
def generate_invoice_number(db: Session, is_proforma: bool = False) -> str:
|
||||
"""Generate a unique invoice number"""
|
||||
# Format: INV-YYYYMMDD-XXXX
|
||||
# Format: INV-YYYYMMDD-XXXX or PRO-YYYYMMDD-XXXX for proforma
|
||||
prefix = "PRO" if is_proforma else "INV"
|
||||
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}-%")
|
||||
Invoice.invoice_number.like(f"{prefix}-{today}-%")
|
||||
).order_by(Invoice.invoice_number.desc()).first()
|
||||
|
||||
if last_invoice:
|
||||
@@ -31,7 +32,7 @@ def generate_invoice_number(db: Session) -> str:
|
||||
else:
|
||||
sequence = 1
|
||||
|
||||
return f"INV-{today}-{sequence:04d}"
|
||||
return f"{prefix}-{today}-{sequence:04d}"
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
@@ -45,6 +46,8 @@ class InvoiceService:
|
||||
tax_rate: float = 0.0,
|
||||
discount_amount: float = 0.0,
|
||||
due_days: int = 30,
|
||||
is_proforma: bool = False,
|
||||
invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit)
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -77,11 +80,17 @@ class InvoiceService:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Generate invoice number
|
||||
invoice_number = generate_invoice_number(db)
|
||||
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
|
||||
|
||||
# If invoice_amount is specified, we need to adjust item calculations
|
||||
# This will be handled in the item creation section below
|
||||
|
||||
# Calculate amounts - subtotal will be recalculated after adding items
|
||||
# Initial subtotal is booking total (room + services)
|
||||
subtotal = float(booking.total_price)
|
||||
# Initial subtotal is booking total (room + services) or invoice_amount if specified
|
||||
if invoice_amount is not None:
|
||||
subtotal = float(invoice_amount)
|
||||
else:
|
||||
subtotal = float(booking.total_price)
|
||||
|
||||
# Calculate tax and total amounts
|
||||
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
|
||||
@@ -121,6 +130,7 @@ class InvoiceService:
|
||||
amount_paid=amount_paid,
|
||||
balance_due=balance_due,
|
||||
status=status,
|
||||
is_proforma=is_proforma,
|
||||
company_name=kwargs.get("company_name"),
|
||||
company_address=kwargs.get("company_address"),
|
||||
company_phone=kwargs.get("company_phone"),
|
||||
@@ -146,17 +156,28 @@ class InvoiceService:
|
||||
services_total = sum(
|
||||
float(su.total_price) for su in booking.service_usages
|
||||
)
|
||||
room_price = float(booking.total_price) - services_total
|
||||
booking_total = float(booking.total_price)
|
||||
room_price = booking_total - services_total
|
||||
|
||||
# Calculate number of nights
|
||||
nights = (booking.check_out_date - booking.check_in_date).days
|
||||
if nights <= 0:
|
||||
nights = 1
|
||||
|
||||
# If invoice_amount is specified (for partial invoices), calculate proportion
|
||||
if invoice_amount is not None and invoice_amount < booking_total:
|
||||
# Calculate proportion for partial invoice
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
room_price = room_price * proportion
|
||||
services_total = services_total * proportion
|
||||
item_description_suffix = f" (Partial: {proportion * 100:.0f}%)"
|
||||
else:
|
||||
item_description_suffix = ""
|
||||
|
||||
# Room item
|
||||
room_item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''})",
|
||||
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''}){item_description_suffix}",
|
||||
quantity=nights,
|
||||
unit_price=room_price / nights if nights > 0 else room_price,
|
||||
tax_rate=tax_rate,
|
||||
@@ -168,14 +189,20 @@ class InvoiceService:
|
||||
|
||||
# Add service items if any
|
||||
for service_usage in booking.service_usages:
|
||||
service_item_price = float(service_usage.total_price)
|
||||
if invoice_amount is not None and invoice_amount < booking_total:
|
||||
# Apply proportion to service items
|
||||
proportion = float(invoice_amount) / booking_total
|
||||
service_item_price = service_item_price * proportion
|
||||
|
||||
service_item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description=f"Service: {service_usage.service.name}",
|
||||
description=f"Service: {service_usage.service.name}{item_description_suffix}",
|
||||
quantity=float(service_usage.quantity),
|
||||
unit_price=float(service_usage.unit_price),
|
||||
unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price,
|
||||
tax_rate=tax_rate,
|
||||
discount_amount=0.0,
|
||||
line_total=float(service_usage.total_price),
|
||||
line_total=service_item_price,
|
||||
service_id=service_usage.service_id,
|
||||
)
|
||||
db.add(service_item)
|
||||
@@ -391,6 +418,7 @@ class InvoiceService:
|
||||
"notes": invoice.notes,
|
||||
"terms_and_conditions": invoice.terms_and_conditions,
|
||||
"payment_instructions": invoice.payment_instructions,
|
||||
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
PayPal payment service for processing PayPal payments
|
||||
"""
|
||||
import logging
|
||||
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
|
||||
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
|
||||
from paypalcheckoutsdk.payments import CapturesRefundRequest
|
||||
@@ -13,6 +14,8 @@ from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_paypal_client_id(db: Session) -> Optional[str]:
|
||||
"""Get PayPal client ID from database or environment variable"""
|
||||
@@ -282,7 +285,7 @@ class PayPalService:
|
||||
raise ValueError(f"PayPal error: {error_msg}")
|
||||
|
||||
@staticmethod
|
||||
def confirm_payment(
|
||||
async def confirm_payment(
|
||||
order_id: str,
|
||||
db: Session,
|
||||
booking_id: Optional[int] = None
|
||||
@@ -337,6 +340,15 @@ class PayPalService:
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
# If still not found, try to find pending deposit payment (for cash bookings with deposit)
|
||||
# This allows updating the payment_method from the default to paypal
|
||||
if not payment:
|
||||
payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.payment_type == PaymentType.deposit,
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
amount = capture_data["amount"]
|
||||
capture_id = capture_data.get("capture_id")
|
||||
|
||||
@@ -347,6 +359,7 @@ class PayPalService:
|
||||
payment.payment_date = datetime.utcnow()
|
||||
# If pending, keep as pending
|
||||
payment.amount = amount
|
||||
payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal
|
||||
if capture_id:
|
||||
payment.transaction_id = f"{order_id}|{capture_id}"
|
||||
else:
|
||||
@@ -380,18 +393,142 @@ class PayPalService:
|
||||
if payment.payment_status == PaymentStatus.completed:
|
||||
db.refresh(booking)
|
||||
|
||||
# Calculate total paid from all completed payments (now includes current payment)
|
||||
# This needs to be calculated before the if/elif blocks
|
||||
total_paid = sum(
|
||||
float(p.amount) for p in booking.payments
|
||||
if p.payment_status == PaymentStatus.completed
|
||||
)
|
||||
|
||||
# Update invoice status based on payment
|
||||
from ..models.invoice import Invoice, InvoiceStatus
|
||||
|
||||
# Find invoices for this booking and update their status
|
||||
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
|
||||
for invoice in invoices:
|
||||
# Update invoice amount_paid and balance_due
|
||||
invoice.amount_paid = total_paid
|
||||
invoice.balance_due = float(invoice.total_amount) - total_paid
|
||||
|
||||
# Update invoice status
|
||||
if invoice.balance_due <= 0:
|
||||
invoice.status = InvoiceStatus.paid
|
||||
invoice.paid_date = datetime.utcnow()
|
||||
elif invoice.amount_paid > 0:
|
||||
invoice.status = InvoiceStatus.sent
|
||||
|
||||
booking_was_confirmed = False
|
||||
should_send_email = False
|
||||
if payment.payment_type == PaymentType.deposit:
|
||||
booking.deposit_paid = True
|
||||
if booking.status == BookingStatus.pending:
|
||||
# Restore cancelled bookings or confirm pending bookings
|
||||
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
booking.status = BookingStatus.confirmed
|
||||
booking_was_confirmed = True
|
||||
should_send_email = True
|
||||
elif booking.status == BookingStatus.confirmed:
|
||||
# Booking already confirmed, but deposit was just paid
|
||||
should_send_email = True
|
||||
elif payment.payment_type == PaymentType.full:
|
||||
total_paid = sum(
|
||||
float(p.amount) for p in booking.payments
|
||||
if p.payment_status == PaymentStatus.completed
|
||||
)
|
||||
|
||||
# Confirm booking and restore cancelled bookings when payment succeeds
|
||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||
booking.status = BookingStatus.confirmed
|
||||
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
booking.status = BookingStatus.confirmed
|
||||
booking_was_confirmed = True
|
||||
should_send_email = True
|
||||
elif booking.status == BookingStatus.confirmed:
|
||||
# Booking already confirmed, but full payment was just completed
|
||||
should_send_email = True
|
||||
|
||||
# Send booking confirmation email if booking was just confirmed or payment completed
|
||||
if should_send_email:
|
||||
try:
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import booking_confirmation_email_template
|
||||
from ..models.system_settings import SystemSettings
|
||||
from ..models.room import Room
|
||||
from sqlalchemy.orm import selectinload
|
||||
import os
|
||||
from ..config.settings import settings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
# Load booking with room details for email
|
||||
booking_with_room = db.query(Booking).options(
|
||||
selectinload(Booking.room).selectinload(Room.room_type)
|
||||
).filter(Booking.id == booking_id).first()
|
||||
|
||||
room = booking_with_room.room if booking_with_room else None
|
||||
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||
|
||||
# Calculate amount paid and remaining due
|
||||
amount_paid = total_paid
|
||||
payment_type_str = payment.payment_type.value if payment.payment_type else None
|
||||
|
||||
email_html = booking_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
room_number=room.room_number if room else "N/A",
|
||||
room_type=room_type_name,
|
||||
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
|
||||
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
|
||||
num_guests=booking.num_guests,
|
||||
total_price=float(booking.total_price),
|
||||
requires_deposit=False, # Payment completed, no deposit message needed
|
||||
deposit_amount=None,
|
||||
amount_paid=amount_paid,
|
||||
payment_type=payment_type_str,
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
if booking.user:
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
subject=f"Booking Confirmed - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
logger.info(f"Booking confirmation email sent to {booking.user.email}")
|
||||
except Exception as email_error:
|
||||
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
|
||||
|
||||
# Send invoice email if payment is completed and invoice is now paid
|
||||
from ..utils.mailer import send_email
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from ..models.invoice import InvoiceStatus
|
||||
|
||||
# Load user for email
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.id == booking.user_id).first()
|
||||
|
||||
for invoice in invoices:
|
||||
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
|
||||
try:
|
||||
invoice_dict = InvoiceService.invoice_to_dict(invoice)
|
||||
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
|
||||
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
|
||||
if user:
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
|
||||
html=invoice_html
|
||||
)
|
||||
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
|
||||
except Exception as email_error:
|
||||
logger.error(f"Failed to send invoice email: {str(email_error)}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Stripe payment service for processing card payments
|
||||
"""
|
||||
import logging
|
||||
import stripe
|
||||
from typing import Optional, Dict, Any
|
||||
from ..config.settings import settings
|
||||
@@ -10,6 +11,8 @@ from ..models.system_settings import SystemSettings
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_stripe_secret_key(db: Session) -> Optional[str]:
|
||||
"""Get Stripe secret key from database or environment variable"""
|
||||
@@ -183,7 +186,7 @@ class StripeService:
|
||||
raise ValueError(f"Stripe error: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def confirm_payment(
|
||||
async def confirm_payment(
|
||||
payment_intent_id: str,
|
||||
db: Session,
|
||||
booking_id: Optional[int] = None
|
||||
@@ -230,6 +233,15 @@ class StripeService:
|
||||
Payment.payment_method == PaymentMethod.stripe
|
||||
).first()
|
||||
|
||||
# If not found, try to find pending deposit payment (for cash bookings with deposit)
|
||||
# This allows updating the payment_method from the default to stripe
|
||||
if not payment:
|
||||
payment = db.query(Payment).filter(
|
||||
Payment.booking_id == booking_id,
|
||||
Payment.payment_type == PaymentType.deposit,
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).order_by(Payment.created_at.desc()).first()
|
||||
|
||||
amount = intent_data["amount"]
|
||||
|
||||
if payment:
|
||||
@@ -240,6 +252,7 @@ class StripeService:
|
||||
payment.payment_date = datetime.utcnow()
|
||||
# If processing, keep as pending (will be updated by webhook)
|
||||
payment.amount = amount
|
||||
payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe
|
||||
else:
|
||||
# Create new payment record
|
||||
payment_type = PaymentType.full
|
||||
@@ -271,25 +284,148 @@ class StripeService:
|
||||
# Refresh booking to get updated payments relationship
|
||||
db.refresh(booking)
|
||||
|
||||
# Calculate total paid from all completed payments (now includes current payment)
|
||||
# This needs to be calculated before the if/elif blocks
|
||||
total_paid = sum(
|
||||
float(p.amount) for p in booking.payments
|
||||
if p.payment_status == PaymentStatus.completed
|
||||
)
|
||||
|
||||
# Update invoice status based on payment
|
||||
from ..models.invoice import Invoice, InvoiceStatus
|
||||
from ..services.invoice_service import InvoiceService
|
||||
|
||||
# Find invoices for this booking and update their status
|
||||
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
|
||||
for invoice in invoices:
|
||||
# Update invoice amount_paid and balance_due
|
||||
invoice.amount_paid = total_paid
|
||||
invoice.balance_due = float(invoice.total_amount) - total_paid
|
||||
|
||||
# Update invoice status
|
||||
if invoice.balance_due <= 0:
|
||||
invoice.status = InvoiceStatus.paid
|
||||
invoice.paid_date = datetime.utcnow()
|
||||
elif invoice.amount_paid > 0:
|
||||
invoice.status = InvoiceStatus.sent
|
||||
|
||||
booking_was_confirmed = False
|
||||
should_send_email = False
|
||||
if payment.payment_type == PaymentType.deposit:
|
||||
# Mark deposit as paid and confirm booking
|
||||
booking.deposit_paid = True
|
||||
if booking.status == BookingStatus.pending:
|
||||
# Restore cancelled bookings or confirm pending bookings
|
||||
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
booking.status = BookingStatus.confirmed
|
||||
booking_was_confirmed = True
|
||||
should_send_email = True
|
||||
elif booking.status == BookingStatus.confirmed:
|
||||
# Booking already confirmed, but deposit was just paid
|
||||
should_send_email = True
|
||||
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
|
||||
# Also restore cancelled bookings when payment succeeds
|
||||
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
|
||||
booking.status = BookingStatus.confirmed
|
||||
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
|
||||
booking.status = BookingStatus.confirmed
|
||||
booking_was_confirmed = True
|
||||
should_send_email = True
|
||||
elif booking.status == BookingStatus.confirmed:
|
||||
# Booking already confirmed, but full payment was just completed
|
||||
should_send_email = True
|
||||
|
||||
# Commit booking status update
|
||||
# Send booking confirmation email if booking was just confirmed or payment completed
|
||||
if should_send_email:
|
||||
try:
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import booking_confirmation_email_template
|
||||
from ..models.system_settings import SystemSettings
|
||||
from ..models.room import Room
|
||||
from sqlalchemy.orm import selectinload
|
||||
import os
|
||||
from ..config.settings import settings
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
# Get platform currency for email
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
|
||||
|
||||
# Get currency symbol
|
||||
currency_symbols = {
|
||||
"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥",
|
||||
"KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
|
||||
"VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$"
|
||||
}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
|
||||
# Load booking with room details for email
|
||||
booking_with_room = db.query(Booking).options(
|
||||
selectinload(Booking.room).selectinload(Room.room_type)
|
||||
).filter(Booking.id == booking_id).first()
|
||||
|
||||
room = booking_with_room.room if booking_with_room else None
|
||||
room_type_name = room.room_type.name if room and room.room_type else "Room"
|
||||
|
||||
# Calculate amount paid and remaining due
|
||||
amount_paid = total_paid
|
||||
payment_type_str = payment.payment_type.value if payment.payment_type else None
|
||||
|
||||
email_html = booking_confirmation_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
room_number=room.room_number if room else "N/A",
|
||||
room_type=room_type_name,
|
||||
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
|
||||
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
|
||||
num_guests=booking.num_guests,
|
||||
total_price=float(booking.total_price),
|
||||
requires_deposit=False, # Payment completed, no deposit message needed
|
||||
deposit_amount=None,
|
||||
amount_paid=amount_paid,
|
||||
payment_type=payment_type_str,
|
||||
client_url=client_url,
|
||||
currency_symbol=currency_symbol
|
||||
)
|
||||
if booking.user:
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
subject=f"Booking Confirmed - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
logger.info(f"Booking confirmation email sent to {booking.user.email}")
|
||||
except Exception as email_error:
|
||||
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
|
||||
|
||||
# Send invoice email if payment is completed and invoice is now paid
|
||||
from ..utils.mailer import send_email
|
||||
from ..services.invoice_service import InvoiceService
|
||||
|
||||
# Load user for email
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.id == booking.user_id).first()
|
||||
|
||||
for invoice in invoices:
|
||||
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
|
||||
try:
|
||||
invoice_dict = InvoiceService.invoice_to_dict(invoice)
|
||||
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
|
||||
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
|
||||
if user:
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
|
||||
html=invoice_html
|
||||
)
|
||||
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
|
||||
except Exception as email_error:
|
||||
logger.error(f"Failed to send invoice email: {str(email_error)}")
|
||||
|
||||
# Commit booking and invoice status updates
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
@@ -335,7 +471,7 @@ class StripeService:
|
||||
raise ValueError(f"Error confirming payment: {error_msg}")
|
||||
|
||||
@staticmethod
|
||||
def handle_webhook(
|
||||
async def handle_webhook(
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
db: Session
|
||||
@@ -375,14 +511,16 @@ class StripeService:
|
||||
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)}")
|
||||
try:
|
||||
await StripeService.confirm_payment(
|
||||
payment_intent_id=payment_intent_id,
|
||||
db=db,
|
||||
booking_id=int(booking_id)
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error processing webhook for booking {booking_id}: {str(e)}")
|
||||
|
||||
elif event["type"] == "payment_intent.payment_failed":
|
||||
payment_intent = event["data"]["object"]
|
||||
@@ -400,6 +538,42 @@ class StripeService:
|
||||
if payment:
|
||||
payment.payment_status = PaymentStatus.failed
|
||||
db.commit()
|
||||
|
||||
# Auto-cancel booking when payment fails
|
||||
booking = db.query(Booking).filter(Booking.id == int(booking_id)).first()
|
||||
if booking and booking.status != BookingStatus.cancelled:
|
||||
booking.status = BookingStatus.cancelled
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Send cancellation email (non-blocking)
|
||||
try:
|
||||
if booking.user:
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import booking_status_changed_email_template
|
||||
from ..models.system_settings import SystemSettings
|
||||
from ..config.settings import settings
|
||||
import os
|
||||
|
||||
# Get client URL from settings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
|
||||
|
||||
email_html = booking_status_changed_email_template(
|
||||
booking_number=booking.booking_number,
|
||||
guest_name=booking.user.full_name if booking.user else "Guest",
|
||||
status="cancelled",
|
||||
client_url=client_url
|
||||
)
|
||||
await send_email(
|
||||
to=booking.user.email,
|
||||
subject=f"Booking Cancelled - {booking.booking_number}",
|
||||
html=email_html
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send cancellation email: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
|
||||
Binary file not shown.
@@ -260,19 +260,66 @@ def booking_confirmation_email_template(
|
||||
total_price: float,
|
||||
requires_deposit: bool,
|
||||
deposit_amount: Optional[float] = None,
|
||||
client_url: str = "http://localhost:5173"
|
||||
amount_paid: Optional[float] = None,
|
||||
payment_type: Optional[str] = None,
|
||||
original_price: Optional[float] = None,
|
||||
discount_amount: Optional[float] = None,
|
||||
promotion_code: Optional[str] = None,
|
||||
client_url: str = "http://localhost:5173",
|
||||
currency_symbol: str = "$"
|
||||
) -> str:
|
||||
"""Booking confirmation email template"""
|
||||
deposit_info = ""
|
||||
if requires_deposit and deposit_amount:
|
||||
if requires_deposit and deposit_amount and amount_paid is None:
|
||||
deposit_info = f"""
|
||||
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
|
||||
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">€{deposit_amount:.2f}</strong> to confirm your booking.</p>
|
||||
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">{currency_symbol}{deposit_amount:.2f}</strong> to confirm your booking.</p>
|
||||
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Payment breakdown section (shown when payment is completed)
|
||||
payment_breakdown = ""
|
||||
if amount_paid is not None:
|
||||
remaining_due = total_price - amount_paid
|
||||
payment_type_label = "Deposit Payment" if payment_type == "deposit" else "Full Payment"
|
||||
payment_breakdown = f"""
|
||||
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.2);">
|
||||
<h3 style="margin-top: 0; margin-bottom: 20px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600;">Payment Information</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Payment Type:</td>
|
||||
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
|
||||
</tr>
|
||||
<tr style="background-color: rgba(16, 185, 129, 0.1);">
|
||||
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Amount Paid:</td>
|
||||
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 18px;">{currency_symbol}{amount_paid:.2f}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
|
||||
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
|
||||
</tr>
|
||||
"""
|
||||
if remaining_due > 0:
|
||||
payment_breakdown += f"""
|
||||
<tr style="background-color: rgba(245, 158, 11, 0.1); border-top: 2px solid #F59E0B;">
|
||||
<td style="padding: 10px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
|
||||
<td style="padding: 10px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
|
||||
</tr>
|
||||
"""
|
||||
else:
|
||||
payment_breakdown += f"""
|
||||
<tr style="background-color: rgba(16, 185, 129, 0.1); border-top: 2px solid #10B981;">
|
||||
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 600;">Status:</td>
|
||||
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 16px;">✅ Fully Paid</td>
|
||||
</tr>
|
||||
"""
|
||||
payment_breakdown += """
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
content = f"""
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
|
||||
@@ -308,13 +355,25 @@ def booking_confirmation_email_template(
|
||||
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
|
||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
|
||||
</tr>
|
||||
{f'''
|
||||
<tr>
|
||||
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Subtotal:</td>
|
||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{original_price:.2f}</td>
|
||||
</tr>
|
||||
<tr style="background-color: rgba(16, 185, 129, 0.1);">
|
||||
<td style="padding: 12px 0; color: #065F46; font-size: 14px; font-weight: 500;">Promotion Discount{f' ({promotion_code})' if promotion_code else ''}:</td>
|
||||
<td style="padding: 12px 0; font-weight: 700; color: #059669; font-size: 15px;">-{currency_symbol}{discount_amount:.2f}</td>
|
||||
</tr>
|
||||
''' if original_price and discount_amount and discount_amount > 0 else ''}
|
||||
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
|
||||
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
|
||||
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">€{total_price:.2f}</td>
|
||||
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{currency_symbol}{total_price:.2f}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{payment_breakdown}
|
||||
|
||||
{deposit_info}
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
@@ -334,7 +393,10 @@ def payment_confirmation_email_template(
|
||||
amount: float,
|
||||
payment_method: str,
|
||||
transaction_id: Optional[str] = None,
|
||||
client_url: str = "http://localhost:5173"
|
||||
payment_type: Optional[str] = None,
|
||||
total_price: Optional[float] = None,
|
||||
client_url: str = "http://localhost:5173",
|
||||
currency_symbol: str = "$"
|
||||
) -> str:
|
||||
"""Payment confirmation email template"""
|
||||
transaction_info = ""
|
||||
@@ -346,6 +408,34 @@ def payment_confirmation_email_template(
|
||||
</tr>
|
||||
"""
|
||||
|
||||
payment_type_info = ""
|
||||
if payment_type:
|
||||
payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment"
|
||||
payment_type_info = f"""
|
||||
<tr>
|
||||
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Type:</td>
|
||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
total_price_info = ""
|
||||
remaining_due_info = ""
|
||||
if total_price is not None:
|
||||
total_price_info = f"""
|
||||
<tr>
|
||||
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
|
||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
|
||||
</tr>
|
||||
"""
|
||||
if payment_type == "deposit" and total_price > amount:
|
||||
remaining_due = total_price - amount
|
||||
remaining_due_info = f"""
|
||||
<tr style="background-color: rgba(245, 158, 11, 0.1);">
|
||||
<td style="padding: 12px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
|
||||
<td style="padding: 12px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
content = f"""
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
|
||||
@@ -370,10 +460,13 @@ def payment_confirmation_email_template(
|
||||
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
|
||||
</tr>
|
||||
{transaction_info}
|
||||
{payment_type_info}
|
||||
{total_price_info}
|
||||
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
|
||||
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
|
||||
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">€{amount:.2f}</td>
|
||||
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">{currency_symbol}{amount:.2f}</td>
|
||||
</tr>
|
||||
{remaining_due_info}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user