# ─── Document Template Renderer ────────────────────────────────────────────────
#
# HTML rendering, template loading, and field mapping.
# Transforms structured document data into rendered HTML via Jinja2 templates.
#
# ─── Imports ───────────────────────────────────────────────────────────────────
import json
from datetime import UTC, datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
# ─── Default Style ─────────────────────────────────────────────────────────────
DEFAULT_STYLE = {
# ── Colors ──
"primary_color": "#2563eb",
"primary_dark": "#1D4ED8",
"primary_light": "#DBEAFE",
"text_primary": "#111827",
"text_secondary": "#6B7280",
"text_tertiary": "#9CA3AF",
"border_color": "#F3F4F6",
"bg_subtle": "#F9FAFB",
# ── Typography ──
"font_family": "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
"font_size": "11px",
# ── Branding ──
"logo_url": "",
"show_watermark": False,
"watermark_text": "PAID",
# ── Page ──
"page_format": "A4",
"orientation": "portrait",
"margin_top": "20mm",
"margin_right": "20mm",
"margin_bottom": "20mm",
"margin_left": "20mm",
}
# ─── Field Mapping ─────────────────────────────────────────────────────────────
def apply_field_mapping(submission: dict, field_mapping: dict) -> dict:
"""Transform raw submission data into document-ready structure.
Takes a form submission and a field mapping config, producing a structured
document data object. This is where free-form form data becomes invoice-ready.
Args:
submission: Raw submission dict from the database
field_mapping: Mapping config defining how form fields → document fields
Returns:
Structured document data dict
"""
data = submission.get("data", {})
if not data:
data = {}
# ── Extract customer info from submission ──
customer = {
"name": submission.get("customer_name", ""),
"email": submission.get("customer_email", ""),
"phone": submission.get("customer_phone", ""),
}
# ── Process line items ──
line_items = _build_line_items(data, field_mapping.get("line_items", []))
# ── Calculate totals ──
subtotal = sum(item.get("amount", 0) for item in line_items)
tax_rate = field_mapping.get("tax_rate", 0.0)
tax = round(subtotal * tax_rate, 2)
total = round(subtotal + tax, 2)
# ── Build document data ──
document_data = {
"from": {
"name": field_mapping.get("business_name", "") or submission.get("business_name", ""),
"email": field_mapping.get("business_email", "") or submission.get("business_email", ""),
"phone": field_mapping.get("business_phone", "") or submission.get("business_phone", ""),
"address": field_mapping.get("business_address", "") or submission.get("business_address", ""),
"tax_id": field_mapping.get("tax_id", ""),
},
"to": customer,
"line_items": line_items,
"subtotal": subtotal,
"tax_rate": tax_rate,
"tax": tax,
"total": total,
"notes": field_mapping.get("notes", ""),
"payment_terms": field_mapping.get("payment_terms", ""),
}
# ── Map custom fields ──
for field_name, field_key in field_mapping.get("custom_fields", {}).items():
document_data[field_name] = data.get(field_key, "")
# Pass through additional fields for non-invoice document types
for extra_field in [
"executive_summary", "scope", "scope_items", "timeline", "terms", "title",
"transactions", "opening_balance", "total_charges", "total_payments", "closing_balance",
"period_start", "period_end",
"tasks", "time_in", "time_out", "total_hours", "work_order_number", "service_address", "scheduled_date",
"po_number", "order_number", "carrier", "tracking_number", "weight",
"contract_title", "preamble", "terms_sections", "effective_date",
]:
if extra_field in data:
document_data[extra_field] = data[extra_field]
# Merge from/to from flat keys if provided
if "from" in data and isinstance(data["from"], dict):
document_data["from"].update(data["from"])
if "to" in data and isinstance(data["to"], dict):
document_data["to"].update(data["to"])
return document_data
def _build_line_items(data: dict, mapping: list) -> list:
"""Build line items from form data based on mapping config.
Mapping format:
[
{"description": "field_key", "quantity": "field_key", "unit_price": "field_key"},
{"description": "field_key", "quantity": "field_key", "unit_price": "field_key"},
]
Each mapping entry produces one line item. Amount is calculated server-side.
"""
line_items = []
for item_map in mapping:
description = data.get(item_map.get("description", ""), "")
quantity = _safe_float(data.get(item_map.get("quantity", "1"), "1"))
unit_price = _safe_float(data.get(item_map.get("unit_price", "0"), "0"))
amount = round(quantity * unit_price, 2)
if description or amount > 0:
line_items.append(
{
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"amount": amount,
}
)
return line_items
def _safe_float(value, default=0.0) -> float:
"""Safely parse a float from form data."""
try:
return float(value) if value is not None else default
except (ValueError, TypeError):
return default
# ─── HTML Rendering ────────────────────────────────────────────────────────────
def render_document_html(document_type: str, layout: str, data: dict, style: dict = None) -> str:
"""Render document data as HTML using Jinja2 templates.
Args:
document_type: 'invoice', 'quote', 'receipt', etc.
layout: 'classic', 'modern', 'receipt', 'statement'
data: Structured document data from apply_field_mapping()
style: Style override dict (colors, fonts, etc.)
Returns:
Rendered HTML string
"""
# Resolve template directory
template_dir = Path(__file__).parent.parent / "templates" / "documents"
if not template_dir.exists():
template_dir = Path(__file__).parent.parent / "templates"
env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=select_autoescape(["html", "htm"]),
)
# Add utility filters
env.filters["currency"] = _currency_filter
env.filters["percent"] = _percent_filter
env.filters["date_format"] = _date_filter
env.filters["to_bool"] = lambda v: bool(v)
env.filters["nl2br"] = lambda v: ("<br>\n").join(str(v).split("\n")) if v else ""
env.filters["signature_line"] = _signature_line_filter
env.filters["clause_number"] = _clause_number_filter
env.filters["uppercase"] = lambda v: str(v).upper() if v else ""
# Merge default style with overrides
style = {**DEFAULT_STYLE, **(style or {})}
# Ensure discount field exists in data
data.setdefault("discount", 0.0)
# Determine template file
template_name = f"{layout}.html"
try:
template = env.get_template(template_name)
except Exception:
# Fallback to classic layout
template = env.get_template("classic.html")
# Build render context
context = {
"document_type": document_type,
"data": data,
"style": style,
"now": datetime.now(UTC),
"title_map": {
"invoice": "INVOICE",
"quote": "QUOTE",
"receipt": "RECEIPT",
"certificate": "CERTIFICATE",
"report": "REPORT",
"proposal": "PROPOSAL",
"statement": "STATEMENT OF ACCOUNT",
"work_order": "WORK ORDER",
"delivery_note": "DELIVERY NOTE",
"contract": "CONTRACT",
},
}
return template.render(**context)
def _currency_filter(value) -> str:
"""Format value as currency string."""
try:
return f"${float(value):,.2f}"
except (ValueError, TypeError):
return "$0.00"
def _percent_filter(value) -> str:
"""Format value as percentage."""
try:
return f"{float(value) * 100:.1f}%"
except (ValueError, TypeError):
return "0.0%"
def _date_filter(value, fmt="%B %d, %Y") -> str:
"""Format date string."""
if isinstance(value, str):
try:
return datetime.fromisoformat(value).strftime(fmt)
except ValueError:
return value
return value if value else ""
def _signature_line_filter(name: str) -> str:
"""Generate a signature line for a named party.
Used in templates: {{ 'John Doe' | signature_line }}
Renders as a signature block with a line and name underneath.
"""
if not name:
name = "Authorized Signature"
return name
def _clause_number_filter(index: int, start: int = 1) -> str:
"""Return clause number formatted as legal numbering (1., 2., 3., etc.)."""
return f"{index + start - 1}. "