Files
Hotel-Booking/Backend/src/services/invoice_service.py
Iliyan Angelov 24b40450dd updates
2025-11-29 17:23:06 +02:00

233 lines
16 KiB
Python

from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus
from ..models.booking import Booking
from ..models.payment import Payment, PaymentStatus
from ..models.user import User
from ..models.system_settings import SystemSettings
from ..config.logging_config import get_logger
logger = get_logger(__name__)
def generate_invoice_number(db: Session, is_proforma: bool=False) -> str:
prefix = 'PRO' if is_proforma else 'INV'
today = datetime.utcnow().strftime('%Y%m%d')
last_invoice = db.query(Invoice).filter(Invoice.invoice_number.like(f'{prefix}-{today}-%')).order_by(Invoice.invoice_number.desc()).first()
if last_invoice:
try:
sequence = int(last_invoice.invoice_number.split('-')[-1])
sequence += 1
except (ValueError, IndexError):
sequence = 1
else:
sequence = 1
return f'{prefix}-{today}-{sequence:04d}'
class InvoiceService:
@staticmethod
def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
from sqlalchemy.orm import selectinload
from ..models.service_usage import ServiceUsage
from ..models.room import Room
from ..models.room_type import RoomType
from ..models.service import Service
logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id})
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload(ServiceUsage.service),
selectinload(Booking.room).selectinload(Room.room_type),
selectinload(Booking.payments)
).filter(Booking.id == booking_id).first()
if not booking:
logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id})
raise ValueError('Booking not found')
user = db.query(User).filter(User.id == booking.user_id).first()
if not user:
raise ValueError('User not found')
# Get tax_rate from system settings if not provided or is 0
if tax_rate == 0.0:
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first()
if tax_rate_setting and tax_rate_setting.value:
try:
tax_rate = float(tax_rate_setting.value)
except (ValueError, TypeError):
tax_rate = 0.0
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
booking_total = float(booking.total_price)
if invoice_amount is not None:
subtotal = float(invoice_amount)
if invoice_amount < booking_total and discount_amount > 0:
if discount_amount > subtotal * 0.5:
proportion = float(invoice_amount) / booking_total
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
discount_amount = original_discount * proportion
else:
subtotal = booking_total
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
amount_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
balance_due = total_amount - amount_paid
if balance_due <= 0:
status = InvoiceStatus.paid
paid_date = datetime.utcnow()
elif amount_paid > 0:
status = InvoiceStatus.sent
paid_date = None
else:
status = InvoiceStatus.draft
paid_date = None
# Get company information from system settings if not provided
company_name = kwargs.get('company_name')
company_address = kwargs.get('company_address')
company_phone = kwargs.get('company_phone')
company_email = kwargs.get('company_email')
company_tax_id = kwargs.get('company_tax_id')
company_logo_url = kwargs.get('company_logo_url')
# If company info not provided, fetch from system settings
if not company_name or not company_address or not company_phone or not company_email:
company_settings = db.query(SystemSettings).filter(
SystemSettings.key.in_(['company_name', 'company_address', 'company_phone', 'company_email', 'company_logo_url'])
).all()
settings_dict = {setting.key: setting.value for setting in company_settings if setting.value}
if not company_name and settings_dict.get('company_name'):
company_name = settings_dict['company_name']
if not company_address and settings_dict.get('company_address'):
company_address = settings_dict['company_address']
if not company_phone and settings_dict.get('company_phone'):
company_phone = settings_dict['company_phone']
if not company_email and settings_dict.get('company_email'):
company_email = settings_dict['company_email']
if not company_logo_url and settings_dict.get('company_logo_url'):
company_logo_url = settings_dict['company_logo_url']
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=company_name, company_address=company_address, company_phone=company_phone, company_email=company_email, company_tax_id=company_tax_id, company_logo_url=company_logo_url, customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
db.add(invoice)
db.flush()
services_total = sum((float(su.total_price) for su in booking.service_usages))
booking_total = float(booking.total_price)
room_price = booking_total - services_total
nights = (booking.check_out_date - booking.check_in_date).days
if nights <= 0:
nights = 1
if invoice_amount is not None and invoice_amount < booking_total:
proportion = float(invoice_amount) / booking_total
room_price = room_price * proportion
services_total = services_total * proportion
item_description_suffix = f' (Partial: {proportion * 100:.0f}%)'
else:
item_description_suffix = ''
room_item = InvoiceItem(invoice_id=invoice.id, description=f'Room: {booking.room.room_number} - {(booking.room.room_type.name if booking.room.room_type else 'N/A')} ({nights} night{('s' if nights > 1 else '')}){item_description_suffix}', quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, line_total=room_price, room_id=booking.room_id)
db.add(room_item)
for service_usage in booking.service_usages:
service_item_price = float(service_usage.total_price)
if invoice_amount is not None and invoice_amount < booking_total:
proportion = float(invoice_amount) / booking_total
service_item_price = service_item_price * proportion
service_item = InvoiceItem(invoice_id=invoice.id, description=f'Service: {service_usage.service.name}{item_description_suffix}', quantity=float(service_usage.quantity), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, line_total=service_item_price, service_id=service_usage.service_id)
db.add(service_item)
subtotal = room_price + services_total
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
balance_due = total_amount - amount_paid
invoice.subtotal = subtotal
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.balance_due = balance_due
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise ValueError('Invoice not found')
allowed_fields = ['company_name', 'company_address', 'company_phone', 'company_email', 'company_tax_id', 'company_logo_url', 'notes', 'terms_and_conditions', 'payment_instructions', 'status', 'due_date', 'tax_rate', 'discount_amount']
for field in allowed_fields:
if field in kwargs:
setattr(invoice, field, kwargs[field])
if 'tax_rate' in kwargs or 'discount_amount' in kwargs:
tax_rate = kwargs.get('tax_rate', invoice.tax_rate)
discount_amount = kwargs.get('discount_amount', invoice.discount_amount)
# Convert decimal types to float for arithmetic operations
subtotal = float(invoice.subtotal) if invoice.subtotal else 0.0
discount_amount = float(discount_amount) if discount_amount else 0.0
tax_rate = float(tax_rate) if tax_rate else 0.0
amount_paid = float(invoice.amount_paid) if invoice.amount_paid else 0.0
invoice.tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
invoice.total_amount = subtotal + invoice.tax_amount - discount_amount
invoice.balance_due = invoice.total_amount - amount_paid
if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.balance_due > 0 and invoice.status == InvoiceStatus.paid:
invoice.status = InvoiceStatus.sent
invoice.paid_date = None
invoice.updated_by_id = updated_by_id
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None, request_id: Optional[str]=None) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise ValueError('Invoice not found')
payment_amount = amount if amount is not None else float(invoice.balance_due)
invoice.amount_paid += payment_amount
invoice.balance_due = invoice.total_amount - invoice.amount_paid
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
else:
invoice.status = InvoiceStatus.sent
invoice.updated_by_id = updated_by_id
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def get_invoice(invoice_id: int, db: Session) -> Optional[Dict[str, Any]]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
return None
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def get_invoices(db: Session, user_id: Optional[int]=None, booking_id: Optional[int]=None, status: Optional[str]=None, page: int=1, limit: int=10) -> Dict[str, Any]:
query = db.query(Invoice)
if user_id:
query = query.filter(Invoice.user_id == user_id)
if booking_id:
query = query.filter(Invoice.booking_id == booking_id)
if status:
try:
status_enum = InvoiceStatus(status)
query = query.filter(Invoice.status == status_enum)
except ValueError:
pass
total = query.count()
offset = (page - 1) * limit
invoices = query.order_by(Invoice.created_at.desc()).offset(offset).limit(limit).all()
return {'invoices': [InvoiceService.invoice_to_dict(inv) for inv in invoices], 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit}
@staticmethod
def invoice_to_dict(invoice: Invoice) -> Dict[str, Any]:
promotion_code = None
if invoice.notes and 'Promotion Code:' in invoice.notes:
try:
promotion_code = invoice.notes.split('Promotion Code:')[1].split('\n')[0].strip()
except:
pass
return {'id': invoice.id, 'invoice_number': invoice.invoice_number, 'booking_id': invoice.booking_id, 'user_id': invoice.user_id, 'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None, 'due_date': invoice.due_date.isoformat() if invoice.due_date else None, 'paid_date': invoice.paid_date.isoformat() if invoice.paid_date else None, 'subtotal': float(invoice.subtotal) if invoice.subtotal else 0.0, 'tax_rate': float(invoice.tax_rate) if invoice.tax_rate else 0.0, 'tax_amount': float(invoice.tax_amount) if invoice.tax_amount else 0.0, 'discount_amount': float(invoice.discount_amount) if invoice.discount_amount else 0.0, 'total_amount': float(invoice.total_amount) if invoice.total_amount else 0.0, 'amount_paid': float(invoice.amount_paid) if invoice.amount_paid else 0.0, 'balance_due': float(invoice.balance_due) if invoice.balance_due else 0.0, 'status': invoice.status.value if invoice.status else None, 'company_name': invoice.company_name, 'company_address': invoice.company_address, 'company_phone': invoice.company_phone, 'company_email': invoice.company_email, 'company_tax_id': invoice.company_tax_id, 'company_logo_url': invoice.company_logo_url, 'customer_name': invoice.customer_name, 'customer_email': invoice.customer_email, 'customer_address': invoice.customer_address, 'customer_phone': invoice.customer_phone, 'customer_tax_id': invoice.customer_tax_id, 'notes': invoice.notes, 'terms_and_conditions': invoice.terms_and_conditions, 'payment_instructions': invoice.payment_instructions, 'is_proforma': invoice.is_proforma if hasattr(invoice, 'is_proforma') else False, 'promotion_code': promotion_code, 'items': [{'id': item.id, 'description': item.description, 'quantity': float(item.quantity) if item.quantity else 0.0, 'unit_price': float(item.unit_price) if item.unit_price else 0.0, 'tax_rate': float(item.tax_rate) if item.tax_rate else 0.0, 'discount_amount': float(item.discount_amount) if item.discount_amount else 0.0, 'line_total': float(item.line_total) if item.line_total else 0.0, 'room_id': item.room_id, 'service_id': item.service_id} for item in invoice.items], 'created_at': invoice.created_at.isoformat() if invoice.created_at else None, 'updated_at': invoice.updated_at.isoformat() if invoice.updated_at else None}