diff --git a/Backend/alembic/versions/__pycache__/bd309b0742c1_add_promotion_fields_to_bookings.cpython-312.pyc b/Backend/alembic/versions/__pycache__/bd309b0742c1_add_promotion_fields_to_bookings.cpython-312.pyc new file mode 100644 index 00000000..8d28a5a7 Binary files /dev/null and b/Backend/alembic/versions/__pycache__/bd309b0742c1_add_promotion_fields_to_bookings.cpython-312.pyc differ diff --git a/Backend/alembic/versions/__pycache__/d9aff6c5f0d4_add_paypal_payment_method.cpython-312.pyc b/Backend/alembic/versions/__pycache__/d9aff6c5f0d4_add_paypal_payment_method.cpython-312.pyc index c8a64c25..a27ee628 100644 Binary files a/Backend/alembic/versions/__pycache__/d9aff6c5f0d4_add_paypal_payment_method.cpython-312.pyc and b/Backend/alembic/versions/__pycache__/d9aff6c5f0d4_add_paypal_payment_method.cpython-312.pyc differ diff --git a/Backend/alembic/versions/__pycache__/f1a2b3c4d5e6_add_is_proforma_to_invoices.cpython-312.pyc b/Backend/alembic/versions/__pycache__/f1a2b3c4d5e6_add_is_proforma_to_invoices.cpython-312.pyc new file mode 100644 index 00000000..a7a4de87 Binary files /dev/null and b/Backend/alembic/versions/__pycache__/f1a2b3c4d5e6_add_is_proforma_to_invoices.cpython-312.pyc differ diff --git a/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py b/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py new file mode 100644 index 00000000..3c6a31e4 --- /dev/null +++ b/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py @@ -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') + diff --git a/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py b/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py new file mode 100644 index 00000000..b265142e --- /dev/null +++ b/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py @@ -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') + diff --git a/Backend/requirements.txt b/Backend/requirements.txt index 48b60df4..7e719c73 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -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 diff --git a/Backend/src/models/__pycache__/booking.cpython-312.pyc b/Backend/src/models/__pycache__/booking.cpython-312.pyc index 821c4040..e856c4bb 100644 Binary files a/Backend/src/models/__pycache__/booking.cpython-312.pyc and b/Backend/src/models/__pycache__/booking.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/invoice.cpython-312.pyc b/Backend/src/models/__pycache__/invoice.cpython-312.pyc index b443ac2b..150faad0 100644 Binary files a/Backend/src/models/__pycache__/invoice.cpython-312.pyc and b/Backend/src/models/__pycache__/invoice.cpython-312.pyc differ diff --git a/Backend/src/models/booking.py b/Backend/src/models/booking.py index 5c725ec6..bfe7bc60 100644 --- a/Backend/src/models/booking.py +++ b/Backend/src/models/booking.py @@ -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) diff --git a/Backend/src/models/invoice.py b/Backend/src/models/invoice.py index 33ce7ac3..2311f2a8 100644 --- a/Backend/src/models/invoice.py +++ b/Backend/src/models/invoice.py @@ -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) diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index d517e811..d585b061 100644 Binary files a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc index 7f221679..9287c3d0 100644 Binary files a/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/contact_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc index 081e677d..bd5a77c9 100644 Binary files a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc index 6b125736..7ba8ef25 100644 Binary files a/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc index e3efd573..92256f58 100644 Binary files a/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 7f677b51..ad952ba4 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -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''' + + {item.get('description', 'N/A')} + {item.get('quantity', 0)} + {item.get('unit_price', 0):.2f} + {item.get('line_total', 0):.2f} + + ''' for item in invoice.get('items', [])]) + + return f""" + + + + + + + +
+
+

{invoice_type}

+
+
+
+

{invoice_type} #{invoice.get('invoice_number', 'N/A')}

+

Issue Date: {invoice.get('issue_date', 'N/A')}

+

Due Date: {invoice.get('due_date', 'N/A')}

+

Status: {invoice.get('status', 'N/A')}

+
+ +
+

Items

+ + + + + + + + + + + {items_html} + +
DescriptionQuantityUnit PriceTotal
+
+ +
+

Subtotal: {invoice.get('subtotal', 0):.2f}

+ {f'

Discount: -{invoice.get("discount_amount", 0):.2f}

' if invoice.get('discount_amount', 0) > 0 else ''} +

Tax: {invoice.get('tax_amount', 0):.2f}

+

Total Amount: {invoice.get('total_amount', 0):.2f}

+

Amount Paid: {invoice.get('amount_paid', 0):.2f}

+

Balance Due: {invoice.get('balance_due', 0):.2f}

+
+
+ +
+ + + """ + + 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: diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py index 0c8b7db5..a6e87bb6 100644 --- a/Backend/src/routes/contact_routes.py +++ b/Backend/src/routes/contact_routes.py @@ -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." ) diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index 9b18ee13..699b91d4 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -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, diff --git a/Backend/src/routes/promotion_routes.py b/Backend/src/routes/promotion_routes.py index 135d55bf..6a525dbf 100644 --- a/Backend/src/routes/promotion_routes.py +++ b/Backend/src/routes/promotion_routes.py @@ -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 diff --git a/Backend/src/routes/system_settings_routes.py b/Backend/src/routes/system_settings_routes.py index ec599fd4..2ee87661 100644 --- a/Backend/src/routes/system_settings_routes.py +++ b/Backend/src/routes/system_settings_routes.py @@ -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)) + diff --git a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc index f61ce899..cc230db6 100644 Binary files a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc and b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc b/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc index ada04cf6..d222bf0b 100644 Binary files a/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc and b/Backend/src/services/__pycache__/paypal_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc b/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc index 7e423939..015ef5b6 100644 Binary files a/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc and b/Backend/src/services/__pycache__/stripe_service.cpython-312.pyc differ diff --git a/Backend/src/services/invoice_service.py b/Backend/src/services/invoice_service.py index e3137cc7..00a192a8 100644 --- a/Backend/src/services/invoice_service.py +++ b/Backend/src/services/invoice_service.py @@ -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, diff --git a/Backend/src/services/paypal_service.py b/Backend/src/services/paypal_service.py index ba3696bc..d613741b 100644 --- a/Backend/src/services/paypal_service.py +++ b/Backend/src/services/paypal_service.py @@ -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) diff --git a/Backend/src/services/stripe_service.py b/Backend/src/services/stripe_service.py index b27e562e..b63fa304 100644 --- a/Backend/src/services/stripe_service.py +++ b/Backend/src/services/stripe_service.py @@ -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", diff --git a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc index b9f275d2..3d6d3478 100644 Binary files a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc and b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc differ diff --git a/Backend/src/utils/email_templates.py b/Backend/src/utils/email_templates.py index 42a4b614..4c9c97fb 100644 --- a/Backend/src/utils/email_templates.py +++ b/Backend/src/utils/email_templates.py @@ -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"""

⚠️ Deposit Required

-

Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

+

Please pay a deposit of {currency_symbol}{deposit_amount:.2f} to confirm your booking.

Your booking will be confirmed once the deposit is received.

""" + # 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""" +
+

Payment Information

+ + + + + + + + + + + + + + """ + if remaining_due > 0: + payment_breakdown += f""" + + + + + """ + else: + payment_breakdown += f""" + + + + + """ + payment_breakdown += """ +
Payment Type:{payment_type_label}
Amount Paid:{currency_symbol}{amount_paid:.2f}
Total Booking Price:{currency_symbol}{total_price:.2f}
Remaining Due:{currency_symbol}{remaining_due:.2f}
Status:✅ Fully Paid
+
+ """ + content = f"""
@@ -308,13 +355,25 @@ def booking_confirmation_email_template( Guests: {num_guests} guest{'s' if num_guests > 1 else ''} + {f''' + + Subtotal: + {currency_symbol}{original_price:.2f} + + + Promotion Discount{f' ({promotion_code})' if promotion_code else ''}: + -{currency_symbol}{discount_amount:.2f} + + ''' if original_price and discount_amount and discount_amount > 0 else ''} Total Price: - €{total_price:.2f} + {currency_symbol}{total_price:.2f}
+ {payment_breakdown} + {deposit_info}
@@ -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( """ + payment_type_info = "" + if payment_type: + payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment" + payment_type_info = f""" + + Payment Type: + {payment_type_label} + + """ + + total_price_info = "" + remaining_due_info = "" + if total_price is not None: + total_price_info = f""" + + Total Booking Price: + {currency_symbol}{total_price:.2f} + + """ + if payment_type == "deposit" and total_price > amount: + remaining_due = total_price - amount + remaining_due_info = f""" + + Remaining Due: + {currency_symbol}{remaining_due:.2f} + + """ + content = f"""
@@ -370,10 +460,13 @@ def payment_confirmation_email_template( {payment_method} {transaction_info} + {payment_type_info} + {total_price_info} Amount Paid: - €{amount:.2f} + {currency_symbol}{amount:.2f} + {remaining_due_info}
diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index b9d3700b..99b1d0d6 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -13,12 +13,14 @@ "@stripe/react-stripe-js": "^2.9.0", "@stripe/stripe-js": "^2.4.0", "@types/react-datepicker": "^6.2.0", + "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.6.2", "date-fns": "^2.30.0", "lucide-react": "^0.294.0", "react": "^18.3.1", "react-datepicker": "^8.9.0", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.48.2", "react-router-dom": "^6.20.0", "react-toastify": "^9.1.3", @@ -1610,6 +1612,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -3179,6 +3190,15 @@ "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", "license": "MIT" }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4124,6 +4144,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-datepicker": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz", @@ -4187,6 +4220,19 @@ "react": "^18.3.1" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hook-form": { "version": "7.65.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 07ee193f..953b59d7 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -11,19 +11,21 @@ }, "dependencies": { "@hookform/resolvers": "^3.3.2", + "@paypal/react-paypal-js": "^8.1.3", "@stripe/react-stripe-js": "^2.9.0", "@stripe/stripe-js": "^2.4.0", "@types/react-datepicker": "^6.2.0", + "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.6.2", "date-fns": "^2.30.0", "lucide-react": "^0.294.0", "react": "^18.3.1", "react-datepicker": "^8.9.0", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.48.2", "react-router-dom": "^6.20.0", "react-toastify": "^9.1.3", - "@paypal/react-paypal-js": "^8.1.3", "yup": "^1.3.3", "zustand": "^4.4.7" }, diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index c2feaa6f..90398ab3 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -216,7 +216,7 @@ function App() { } /> diff --git a/Frontend/src/components/common/Recaptcha.tsx b/Frontend/src/components/common/Recaptcha.tsx new file mode 100644 index 00000000..f9aaf4b2 --- /dev/null +++ b/Frontend/src/components/common/Recaptcha.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useRef, useState } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { recaptchaService } from '../../services/api/systemSettingsService'; + +interface RecaptchaProps { + onChange?: (token: string | null) => void; + onError?: (error: string) => void; + theme?: 'light' | 'dark'; + size?: 'normal' | 'compact'; + className?: string; +} + +const Recaptcha: React.FC = ({ + onChange, + onError, + theme = 'dark', + size = 'normal', + className = '', +}) => { + const recaptchaRef = useRef(null); + const [siteKey, setSiteKey] = useState(''); + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchSettings = async () => { + try { + const response = await recaptchaService.getRecaptchaSettings(); + if (response.status === 'success' && response.data) { + setSiteKey(response.data.recaptcha_site_key || ''); + setEnabled(response.data.recaptcha_enabled || false); + } + } catch (error) { + console.error('Error fetching reCAPTCHA settings:', error); + if (onError) { + onError('Failed to load reCAPTCHA settings'); + } + } finally { + setLoading(false); + } + }; + + fetchSettings(); + }, [onError]); + + const handleChange = (token: string | null) => { + if (onChange) { + onChange(token); + } + }; + + const handleExpired = () => { + if (onChange) { + onChange(null); + } + }; + + const handleError = () => { + if (onError) { + onError('reCAPTCHA error occurred'); + } + if (onChange) { + onChange(null); + } + }; + + if (loading) { + return null; + } + + if (!enabled || !siteKey) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default Recaptcha; + diff --git a/Frontend/src/components/payments/PayPalPaymentWrapper.tsx b/Frontend/src/components/payments/PayPalPaymentWrapper.tsx index 696d29ac..00788b69 100644 --- a/Frontend/src/components/payments/PayPalPaymentWrapper.tsx +++ b/Frontend/src/components/payments/PayPalPaymentWrapper.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { createPayPalOrder } from '../../services/api/paymentService'; import { Loader2, AlertCircle } from 'lucide-react'; +import { useFormatCurrency } from '../../hooks/useFormatCurrency'; interface PayPalPaymentWrapperProps { bookingId: number; @@ -12,9 +13,12 @@ interface PayPalPaymentWrapperProps { const PayPalPaymentWrapper: React.FC = ({ bookingId, amount, - currency = 'USD', + currency: propCurrency, onError, }) => { + // Get currency from context if not provided as prop + const { currency: contextCurrency } = useFormatCurrency(); + const currency = propCurrency || contextCurrency || 'USD'; const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [approvalUrl, setApprovalUrl] = useState(null); @@ -75,22 +79,29 @@ const PayPalPaymentWrapper: React.FC = ({ if (loading) { return (
- - Initializing PayPal payment... +
+ +
+ + Initializing PayPal payment... +
); } if (error) { return ( -
-
- +
+
+
-

+

Payment Initialization Failed

-

+

{error || 'Unable to initialize PayPal payment. Please try again.'}

@@ -102,57 +113,65 @@ const PayPalPaymentWrapper: React.FC = ({ if (!approvalUrl) { return (
- - Loading PayPal... +
+ +
+ + Loading PayPal... +
); } return ( -
-
-
- - - -
-

- Complete Payment with PayPal -

-

- You will be redirected to PayPal to securely complete your payment of{' '} - - {new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency, - }).format(amount)} - -

- -

- Secure payment powered by PayPal -

+ +
+

+ Complete Payment with PayPal +

+

+ You will be redirected to PayPal to securely complete your payment of{' '} + + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + }).format(amount)} + +

+ +

+ Secure payment powered by PayPal +

); }; diff --git a/Frontend/src/components/rooms/RatingStars.tsx b/Frontend/src/components/rooms/RatingStars.tsx index 0e3a23d8..8d9c8931 100644 --- a/Frontend/src/components/rooms/RatingStars.tsx +++ b/Frontend/src/components/rooms/RatingStars.tsx @@ -71,15 +71,15 @@ const RatingStars: React.FC = ({ ); })} {showNumber && ( - + {rating.toFixed(1)} )} diff --git a/Frontend/src/components/rooms/ReviewSection.tsx b/Frontend/src/components/rooms/ReviewSection.tsx index 56efa3fc..bdb5d050 100644 --- a/Frontend/src/components/rooms/ReviewSection.tsx +++ b/Frontend/src/components/rooms/ReviewSection.tsx @@ -10,6 +10,8 @@ import { type Review, } from '../../services/api/reviewService'; import useAuthStore from '../../store/useAuthStore'; +import Recaptcha from '../common/Recaptcha'; +import { recaptchaService } from '../../services/api/systemSettingsService'; interface ReviewSectionProps { roomId: number; @@ -42,6 +44,7 @@ const ReviewSection: React.FC = ({ const [submitting, setSubmitting] = useState(false); const [averageRating, setAverageRating] = useState(0); const [totalReviews, setTotalReviews] = useState(0); + const [recaptchaToken, setRecaptchaToken] = useState(null); const { register, @@ -87,6 +90,22 @@ const ReviewSection: React.FC = ({ return; } + // Verify reCAPTCHA if enabled + if (recaptchaToken) { + try { + const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken); + if (verifyResponse.status === 'error' || !verifyResponse.data.verified) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } catch (error) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } + try { setSubmitting(true); const response = await createReview({ @@ -101,12 +120,14 @@ const ReviewSection: React.FC = ({ ); reset(); fetchReviews(); + setRecaptchaToken(null); } } catch (error: any) { const message = error.response?.data?.message || 'Unable to submit review'; toast.error(message); + setRecaptchaToken(null); } finally { setSubmitting(false); } @@ -121,24 +142,26 @@ const ReviewSection: React.FC = ({ }; return ( -
+
{/* Rating Summary */} -
-

+
+

Customer Reviews

-
+
-
+
{averageRating > 0 ? averageRating.toFixed(1) : 'N/A'}
- -
+
+ +
+
{totalReviews} review{totalReviews !== 1 ? 's' : ''}
@@ -147,29 +170,29 @@ const ReviewSection: React.FC = ({ {/* Review Form */} {isAuthenticated ? ( -
-

+
+

Write Your Review

- setValue('rating', value) } /> {errors.rating && ( -

+

{errors.rating.message}

)} @@ -178,51 +201,66 @@ const ReviewSection: React.FC = ({