This commit is contained in:
Iliyan Angelov
2025-11-20 02:18:52 +02:00
parent 34b4c969d4
commit 44e11520c5
55 changed files with 4741 additions and 876 deletions

View File

@@ -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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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."
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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))

View File

@@ -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,

View File

@@ -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)

View File

@@ -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",

View File

@@ -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>