Files
delphi-database/app/services/billing.py
HotSwapp ae4484381f progress
2025-08-16 10:05:42 -05:00

539 lines
20 KiB
Python

"""
Billing statement generation service
Handles statement creation, template rendering, and PDF generation
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import date, datetime, timedelta
from decimal import Decimal
from jinja2 import Template, Environment, BaseLoader
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func
from app.models import (
BillingStatement, StatementTemplate, BillingStatementItem,
File, Ledger, Rolodex, StatementStatus
)
from app.utils.logging import app_logger
logger = app_logger
class StatementGenerationError(Exception):
"""Exception raised when statement generation fails"""
pass
class BillingStatementService:
"""Service for generating and managing billing statements"""
def __init__(self, db: Session):
self.db = db
self.jinja_env = Environment(loader=BaseLoader())
def generate_statement_number(self) -> str:
"""Generate unique statement number"""
today = date.today()
prefix = f"STMT-{today.strftime('%Y%m')}"
# Find highest number for this month
last_stmt = self.db.query(BillingStatement).filter(
BillingStatement.statement_number.like(f"{prefix}%")
).order_by(BillingStatement.statement_number.desc()).first()
if last_stmt:
try:
last_num = int(last_stmt.statement_number.split('-')[-1])
next_num = last_num + 1
except (ValueError, IndexError):
next_num = 1
else:
next_num = 1
return f"{prefix}-{next_num:04d}"
def get_unbilled_transactions(
self,
file_no: str,
period_start: date = None,
period_end: date = None
) -> List[Ledger]:
"""Get unbilled transactions for a file within date range"""
query = self.db.query(Ledger).filter(
Ledger.file_no == file_no,
Ledger.billed == "N"
)
if period_start:
query = query.filter(Ledger.date >= period_start)
if period_end:
query = query.filter(Ledger.date <= period_end)
return query.order_by(Ledger.date).all()
def calculate_statement_totals(self, transactions: List[Ledger]) -> Dict[str, float]:
"""Calculate financial totals for statement"""
totals = {
'fees': 0.0,
'costs': 0.0,
'payments': 0.0,
'trust_deposits': 0.0,
'trust_transfers': 0.0,
'adjustments': 0.0,
'current_charges': 0.0,
'net_charges': 0.0
}
for txn in transactions:
amount = float(txn.amount or 0.0)
# Categorize by transaction type
if txn.t_type in ['1', '2']: # Fees (hourly and flat)
totals['fees'] += amount
totals['current_charges'] += amount
elif txn.t_type == '3': # Costs/disbursements
totals['costs'] += amount
totals['current_charges'] += amount
elif txn.t_type == '4': # Payments
totals['payments'] += amount
totals['net_charges'] -= amount
elif txn.t_type == '5': # Trust deposits
totals['trust_deposits'] += amount
# Add more categorization as needed
totals['net_charges'] = totals['current_charges'] - totals['payments']
return totals
def get_previous_balance(self, file_no: str, period_start: date) -> float:
"""Calculate previous balance before statement period"""
# Sum all transactions before period start
result = self.db.query(func.sum(Ledger.amount)).filter(
Ledger.file_no == file_no,
Ledger.date < period_start
).scalar()
return float(result or 0.0)
def get_trust_balance(self, file_no: str) -> float:
"""Get current trust account balance for file"""
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
return float(file_obj.trust_bal or 0.0) if file_obj else 0.0
def create_statement(
self,
file_no: str,
period_start: date,
period_end: date,
template_id: Optional[int] = None,
custom_footer: Optional[str] = None,
created_by: Optional[str] = None
) -> BillingStatement:
"""Create a new billing statement for a file"""
# Get file and customer info
file_obj = self.db.query(File).options(
joinedload(File.owner)
).filter(File.file_no == file_no).first()
if not file_obj:
raise StatementGenerationError(f"File {file_no} not found")
# Get unbilled transactions
transactions = self.get_unbilled_transactions(file_no, period_start, period_end)
if not transactions:
raise StatementGenerationError(f"No unbilled transactions found for file {file_no}")
# Calculate totals
totals = self.calculate_statement_totals(transactions)
previous_balance = self.get_previous_balance(file_no, period_start)
trust_balance = self.get_trust_balance(file_no)
# Calculate total due
total_due = previous_balance + totals['current_charges'] - totals['payments']
# Get or create default template
if not template_id:
template = self.db.query(StatementTemplate).filter(
StatementTemplate.is_default == True,
StatementTemplate.is_active == True
).first()
if template:
template_id = template.id
# Create statement
statement = BillingStatement(
statement_number=self.generate_statement_number(),
file_no=file_no,
customer_id=file_obj.owner.id if file_obj.owner else None,
period_start=period_start,
period_end=period_end,
statement_date=date.today(),
due_date=date.today() + timedelta(days=30),
previous_balance=previous_balance,
current_charges=totals['current_charges'],
payments_credits=totals['payments'],
total_due=total_due,
trust_balance=trust_balance,
template_id=template_id,
billed_transaction_count=len(transactions),
custom_footer=custom_footer,
created_by=created_by,
status=StatementStatus.DRAFT
)
self.db.add(statement)
self.db.flush() # Get the statement ID
# Create statement items
for txn in transactions:
item = BillingStatementItem(
statement_id=statement.id,
ledger_id=txn.id,
date=txn.date,
description=txn.note or f"{txn.t_code} - {txn.empl_num}",
quantity=float(txn.quantity or 0.0),
rate=float(txn.rate or 0.0),
amount=float(txn.amount or 0.0),
item_category=self._categorize_transaction(txn)
)
self.db.add(item)
self.db.commit()
self.db.refresh(statement)
logger.info(f"Created statement {statement.statement_number} for file {file_no}")
return statement
def _categorize_transaction(self, txn: Ledger) -> str:
"""Categorize transaction for statement display"""
if txn.t_type in ['1', '2']:
return 'fees'
elif txn.t_type == '3':
return 'costs'
elif txn.t_type == '4':
return 'payments'
elif txn.t_type == '5':
return 'trust'
else:
return 'other'
def generate_statement_html(self, statement_id: int) -> str:
"""Generate HTML content for a statement"""
statement = self.db.query(BillingStatement).options(
joinedload(BillingStatement.file).joinedload(File.owner),
joinedload(BillingStatement.customer),
joinedload(BillingStatement.template),
joinedload(BillingStatement.statement_items)
).filter(BillingStatement.id == statement_id).first()
if not statement:
raise StatementGenerationError(f"Statement {statement_id} not found")
# Prepare template context
context = self._prepare_template_context(statement)
# Get template or use default
template_content = self._get_statement_template(statement)
# Render template
template = self.jinja_env.from_string(template_content)
html_content = template.render(**context)
# Save generated HTML
statement.html_content = html_content
self.db.commit()
return html_content
def _prepare_template_context(self, statement: BillingStatement) -> Dict[str, Any]:
"""Prepare context data for template rendering"""
# Group statement items by category
items_by_category = {}
for item in statement.statement_items:
category = item.item_category or 'other'
if category not in items_by_category:
items_by_category[category] = []
items_by_category[category].append(item)
return {
'statement': statement,
'file': statement.file,
'customer': statement.customer or statement.file.owner,
'items_by_category': items_by_category,
'total_fees': sum(item.amount for item in items_by_category.get('fees', [])),
'total_costs': sum(item.amount for item in items_by_category.get('costs', [])),
'total_payments': sum(item.amount for item in items_by_category.get('payments', [])),
'generation_date': datetime.now(),
'custom_footer': statement.custom_footer
}
def _get_statement_template(self, statement: BillingStatement) -> str:
"""Get template content for statement"""
if statement.template and statement.template.is_active:
# Use custom template
header = statement.template.header_template or ""
footer = statement.template.footer_template or ""
css = statement.template.css_styles or ""
return self._build_complete_template(header, footer, css)
else:
# Use default template
return self._get_default_template()
def _build_complete_template(self, header: str, footer: str, css: str) -> str:
"""Build complete HTML template from components"""
# Use regular string formatting to avoid f-string conflicts with Jinja2
template = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Billing Statement - {{ statement.statement_number }}</title>
<style>
%(css)s
body { font-family: Arial, sans-serif; margin: 40px; }
.statement-header { margin-bottom: 30px; }
.statement-details { margin-bottom: 20px; }
.statement-items { margin-bottom: 30px; }
.statement-footer { margin-top: 30px; }
table { width: 100%%; border-collapse: collapse; }
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
.amount { text-align: right; }
.total-row { font-weight: bold; border-top: 2px solid #000; }
</style>
</head>
<body>
<div class="statement-header">
%(header)s
</div>
<div class="statement-content">
<!-- Default statement content will be inserted here -->
{{ self.default_content() }}
</div>
<div class="statement-footer">
%(footer)s
{%% if custom_footer %%}
<div class="custom-footer">{{ custom_footer }}</div>
{%% endif %%}
</div>
</body>
</html>
""" % {"css": css, "header": header, "footer": footer}
return template
def _get_default_template(self) -> str:
"""Get default statement template"""
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Billing Statement - {{ statement.statement_number }}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.header { text-align: center; margin-bottom: 30px; }
.client-info { margin-bottom: 20px; }
.statement-details { margin-bottom: 20px; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
.items-table th, .items-table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
.amount { text-align: right; }
.total-row { font-weight: bold; border-top: 2px solid #000; }
.summary { margin-top: 20px; }
.footer { margin-top: 40px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="header">
<h1>BILLING STATEMENT</h1>
<h2>{{ statement.statement_number }}</h2>
</div>
<div class="client-info">
<strong>Bill To:</strong><br>
{{ customer.first }} {{ customer.last }}<br>
{% if customer.a1 %}{{ customer.a1 }}<br>{% endif %}
{% if customer.a2 %}{{ customer.a2 }}<br>{% endif %}
{% if customer.city %}{{ customer.city }}, {{ customer.abrev }} {{ customer.zip }}{% endif %}
</div>
<div class="statement-details">
<table>
<tr><td><strong>File Number:</strong></td><td>{{ statement.file_no }}</td></tr>
<tr><td><strong>Statement Date:</strong></td><td>{{ statement.statement_date.strftime('%m/%d/%Y') }}</td></tr>
<tr><td><strong>Period:</strong></td><td>{{ statement.period_start.strftime('%m/%d/%Y') }} - {{ statement.period_end.strftime('%m/%d/%Y') }}</td></tr>
<tr><td><strong>Due Date:</strong></td><td>{{ statement.due_date.strftime('%m/%d/%Y') if statement.due_date else 'Upon Receipt' }}</td></tr>
</table>
</div>
<!-- Fees Section -->
{% if items_by_category.fees %}
<h3>Professional Services</h3>
<table class="items-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Quantity</th>
<th>Rate</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items_by_category.fees %}
<tr>
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
<td>{{ item.description }}</td>
<td>{{ "%.2f"|format(item.quantity) if item.quantity else '' }}</td>
<td>{{ "$%.2f"|format(item.rate) if item.rate else '' }}</td>
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="4">Total Professional Services</td>
<td class="amount">${{ "%.2f"|format(total_fees) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<!-- Costs Section -->
{% if items_by_category.costs %}
<h3>Costs and Disbursements</h3>
<table class="items-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items_by_category.costs %}
<tr>
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
<td>{{ item.description }}</td>
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="2">Total Costs and Disbursements</td>
<td class="amount">${{ "%.2f"|format(total_costs) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<!-- Payments Section -->
{% if items_by_category.payments %}
<h3>Payments and Credits</h3>
<table class="items-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items_by_category.payments %}
<tr>
<td>{{ item.date.strftime('%m/%d/%Y') }}</td>
<td>{{ item.description }}</td>
<td class="amount">${{ "%.2f"|format(item.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="2">Total Payments and Credits</td>
<td class="amount">${{ "%.2f"|format(total_payments) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<!-- Summary -->
<div class="summary">
<table class="items-table">
<tr>
<td><strong>Previous Balance:</strong></td>
<td class="amount">${{ "%.2f"|format(statement.previous_balance) }}</td>
</tr>
<tr>
<td><strong>Current Charges:</strong></td>
<td class="amount">${{ "%.2f"|format(statement.current_charges) }}</td>
</tr>
<tr>
<td><strong>Payments/Credits:</strong></td>
<td class="amount">${{ "%.2f"|format(statement.payments_credits) }}</td>
</tr>
<tr class="total-row">
<td><strong>TOTAL DUE:</strong></td>
<td class="amount"><strong>${{ "%.2f"|format(statement.total_due) }}</strong></td>
</tr>
</table>
</div>
{% if statement.trust_balance > 0 %}
<div class="trust-info">
<p><strong>Trust Account Balance:</strong> ${{ "%.2f"|format(statement.trust_balance) }}</p>
</div>
{% endif %}
<div class="footer">
<p>Thank you for your business. Please remit payment by the due date.</p>
{% if custom_footer %}
<p>{{ custom_footer }}</p>
{% endif %}
<p><em>Generated on {{ generation_date.strftime('%m/%d/%Y at %I:%M %p') }}</em></p>
</div>
</body>
</html>
"""
def approve_statement(self, statement_id: int, approved_by: str) -> BillingStatement:
"""Approve a statement and mark transactions as billed"""
statement = self.db.query(BillingStatement).filter(
BillingStatement.id == statement_id
).first()
if not statement:
raise StatementGenerationError(f"Statement {statement_id} not found")
if statement.status != StatementStatus.DRAFT:
raise StatementGenerationError(f"Only draft statements can be approved")
# Mark statement as approved
statement.status = StatementStatus.APPROVED
statement.approved_by = approved_by
statement.approved_at = datetime.now()
# Mark all related transactions as billed
ledger_ids = [item.ledger_id for item in statement.statement_items]
self.db.query(Ledger).filter(
Ledger.id.in_(ledger_ids)
).update({Ledger.billed: "Y"}, synchronize_session=False)
self.db.commit()
logger.info(f"Approved statement {statement.statement_number} by {approved_by}")
return statement
def mark_statement_sent(self, statement_id: int, sent_by: str) -> BillingStatement:
"""Mark statement as sent"""
statement = self.db.query(BillingStatement).filter(
BillingStatement.id == statement_id
).first()
if not statement:
raise StatementGenerationError(f"Statement {statement_id} not found")
statement.status = StatementStatus.SENT
statement.sent_by = sent_by
statement.sent_at = datetime.now()
self.db.commit()
logger.info(f"Marked statement {statement.statement_number} as sent by {sent_by}")
return statement