f5ca91ee02
- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width - Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable - Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files - Admin: "Import Existing Media" button → POST /api/admin/import-media-assets - Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path - SSE task logs: task_logs.py core + router, LiveRenderLog component - CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render - render-worker: add trimesh layer to Dockerfile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
12 KiB
Python
378 lines
12 KiB
Python
"""Billing service — pricing (price lookup + order price computation) and invoice CRUD + PDF generation.
|
|
|
|
Price resolution cascade for order lines:
|
|
1. OutputType's linked pricing_tier (if active) → use its price_per_item
|
|
2. Product's category_key → look up PricingTier by category
|
|
3. "default" category tier → global fallback
|
|
4. None if nothing configured
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select, update as sql_update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.domains.billing.models import Invoice, InvoiceLine, PricingTier
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def get_price_for(
|
|
db: AsyncSession,
|
|
category_key: str,
|
|
quality_level: str = "Normal",
|
|
) -> Decimal | None:
|
|
"""Return price_per_item for the given category + quality level."""
|
|
# 1. Exact match
|
|
result = await db.execute(
|
|
select(PricingTier).where(
|
|
PricingTier.category_key == category_key,
|
|
PricingTier.quality_level == quality_level,
|
|
PricingTier.is_active.is_(True),
|
|
)
|
|
)
|
|
tier = result.scalar_one_or_none()
|
|
if tier is not None:
|
|
return tier.price_per_item
|
|
|
|
if category_key == "default":
|
|
return None
|
|
|
|
# 2. Fallback: default category
|
|
result = await db.execute(
|
|
select(PricingTier).where(
|
|
PricingTier.category_key == "default",
|
|
PricingTier.quality_level == quality_level,
|
|
PricingTier.is_active.is_(True),
|
|
)
|
|
)
|
|
tier = result.scalar_one_or_none()
|
|
return tier.price_per_item if tier is not None else None
|
|
|
|
|
|
async def resolve_line_price(
|
|
db: AsyncSession,
|
|
output_type_id: str | None,
|
|
product_category_key: str | None,
|
|
) -> Decimal | None:
|
|
"""Resolve the unit price for a single order line using the cascade."""
|
|
if output_type_id is not None:
|
|
from app.domains.rendering.models import OutputType
|
|
result = await db.execute(
|
|
select(OutputType)
|
|
.options(selectinload(OutputType.pricing_tier))
|
|
.where(OutputType.id == output_type_id)
|
|
)
|
|
ot = result.scalar_one_or_none()
|
|
if ot and ot.pricing_tier and ot.pricing_tier.is_active:
|
|
return ot.pricing_tier.price_per_item
|
|
|
|
# Step 2+3: category lookup with default fallback
|
|
cat = product_category_key or "default"
|
|
return await get_price_for(db, cat)
|
|
|
|
|
|
async def estimate_order_price(
|
|
db: AsyncSession,
|
|
lines: list[dict[str, Any]],
|
|
) -> dict:
|
|
"""Estimate price for a list of prospective order lines."""
|
|
from app.domains.products.models import Product
|
|
|
|
breakdown: list[dict] = []
|
|
total = Decimal("0.00")
|
|
has_unpriced = False
|
|
|
|
for line in lines:
|
|
product_id = line.get("product_id")
|
|
output_type_id = line.get("output_type_id")
|
|
|
|
# Get product category
|
|
cat = None
|
|
if product_id:
|
|
prod_result = await db.execute(
|
|
select(Product).where(Product.id == product_id)
|
|
)
|
|
prod = prod_result.scalar_one_or_none()
|
|
if prod:
|
|
cat = prod.category_key
|
|
|
|
price = await resolve_line_price(db, output_type_id, cat)
|
|
|
|
breakdown.append({
|
|
"output_type_id": str(output_type_id) if output_type_id else None,
|
|
"product_id": str(product_id) if product_id else None,
|
|
"unit_price": float(price) if price is not None else None,
|
|
})
|
|
|
|
if price is not None:
|
|
total += price
|
|
else:
|
|
has_unpriced = True
|
|
|
|
return {
|
|
"total": float(total),
|
|
"line_count": len(lines),
|
|
"breakdown": breakdown,
|
|
"has_unpriced": has_unpriced,
|
|
}
|
|
|
|
|
|
async def refresh_order_price(db: AsyncSession, order_id) -> Decimal | None:
|
|
"""Re-fetch order + lines, resolve per-line prices, snapshot to unit_price, update order total."""
|
|
from app.domains.orders.models import Order, OrderLine
|
|
from app.domains.rendering.models import OutputType
|
|
|
|
order_result = await db.execute(select(Order).where(Order.id == order_id))
|
|
order = order_result.scalar_one_or_none()
|
|
if order is None:
|
|
return None
|
|
|
|
lines_result = await db.execute(
|
|
select(OrderLine)
|
|
.options(
|
|
selectinload(OrderLine.output_type).selectinload(OutputType.pricing_tier),
|
|
selectinload(OrderLine.product),
|
|
)
|
|
.where(
|
|
OrderLine.order_id == order_id,
|
|
OrderLine.output_type_id.is_not(None),
|
|
)
|
|
)
|
|
lines = lines_result.scalars().all()
|
|
|
|
if not lines:
|
|
await db.execute(
|
|
sql_update(Order)
|
|
.where(Order.id == order_id)
|
|
.values(estimated_price=Decimal("0.00"))
|
|
)
|
|
await db.commit()
|
|
return Decimal("0.00")
|
|
|
|
total = Decimal("0.00")
|
|
any_priced = False
|
|
|
|
for line in lines:
|
|
# Cascade: 1) OT pricing tier, 2) product category, 3) default
|
|
price = None
|
|
if line.output_type and line.output_type.pricing_tier and line.output_type.pricing_tier.is_active:
|
|
price = line.output_type.pricing_tier.price_per_item
|
|
else:
|
|
cat = line.product.category_key if line.product else None
|
|
price = await get_price_for(db, cat or "default")
|
|
|
|
# Snapshot to line
|
|
await db.execute(
|
|
sql_update(OrderLine)
|
|
.where(OrderLine.id == line.id)
|
|
.values(unit_price=price)
|
|
)
|
|
|
|
if price is not None:
|
|
total += price
|
|
any_priced = True
|
|
|
|
new_price = total if any_priced else None
|
|
|
|
await db.execute(
|
|
sql_update(Order)
|
|
.where(Order.id == order_id)
|
|
.values(estimated_price=new_price)
|
|
)
|
|
await db.commit()
|
|
return new_price
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invoice CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_STATUSES = {"draft", "sent", "paid", "cancelled"}
|
|
|
|
|
|
async def generate_invoice_number(db: AsyncSession, tenant_id: uuid.UUID | None) -> str:
|
|
"""Generate sequential invoice number: INV-YYYY-NNNN.
|
|
|
|
Uses a PostgreSQL advisory lock to prevent race conditions when multiple
|
|
requests create invoices concurrently. The lock is automatically released
|
|
when the surrounding transaction commits or rolls back.
|
|
"""
|
|
from sqlalchemy import text
|
|
year = datetime.utcnow().year
|
|
# Advisory lock keyed on year — serialises concurrent invoice creation
|
|
await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"invoice_number_seq_{year}"})
|
|
count_result = await db.execute(
|
|
select(func.count()).select_from(Invoice).where(
|
|
func.extract("year", Invoice.created_at) == year
|
|
)
|
|
)
|
|
seq = (count_result.scalar() or 0) + 1
|
|
return f"INV-{year}-{seq:04d}"
|
|
|
|
|
|
async def create_invoice(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID | None,
|
|
order_line_ids: list[uuid.UUID],
|
|
notes: str | None = None,
|
|
issued_at: date | None = None,
|
|
due_at: date | None = None,
|
|
vat_rate: Decimal = Decimal("0.19"),
|
|
currency: str = "EUR",
|
|
) -> Invoice:
|
|
"""Create invoice with lines derived from order lines."""
|
|
from app.domains.orders.models import OrderLine
|
|
|
|
invoice_number = await generate_invoice_number(db, tenant_id)
|
|
invoice = Invoice(
|
|
tenant_id=tenant_id,
|
|
invoice_number=invoice_number,
|
|
status="draft",
|
|
issued_at=issued_at or date.today(),
|
|
due_at=due_at,
|
|
notes=notes,
|
|
vat_rate=vat_rate,
|
|
currency=currency,
|
|
)
|
|
db.add(invoice)
|
|
await db.flush() # get invoice.id
|
|
|
|
total_net = Decimal("0")
|
|
for ol_id in order_line_ids:
|
|
result = await db.execute(select(OrderLine).where(OrderLine.id == ol_id))
|
|
ol = result.scalar_one_or_none()
|
|
if not ol:
|
|
continue
|
|
unit_price = ol.unit_price or Decimal("0")
|
|
line = InvoiceLine(
|
|
invoice_id=invoice.id,
|
|
order_line_id=ol.id,
|
|
description=f"Render: {ol.id}",
|
|
quantity=1,
|
|
unit_price=unit_price,
|
|
total=unit_price,
|
|
)
|
|
db.add(line)
|
|
total_net += unit_price
|
|
|
|
invoice.total_net = total_net
|
|
invoice.total_vat = (total_net * vat_rate).quantize(Decimal("0.01"))
|
|
await db.commit()
|
|
return await get_invoice(db, invoice.id)
|
|
|
|
|
|
async def get_invoices(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID | None = None,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
) -> list[Invoice]:
|
|
q = (
|
|
select(Invoice)
|
|
.options(selectinload(Invoice.lines))
|
|
.order_by(Invoice.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
)
|
|
result = await db.execute(q)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_invoice(db: AsyncSession, invoice_id: uuid.UUID) -> Invoice | None:
|
|
result = await db.execute(
|
|
select(Invoice)
|
|
.options(selectinload(Invoice.lines))
|
|
.where(Invoice.id == invoice_id)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def update_invoice_status(db: AsyncSession, invoice_id: uuid.UUID, status: str) -> Invoice | None:
|
|
invoice = await get_invoice(db, invoice_id)
|
|
if not invoice:
|
|
return None
|
|
invoice.status = status
|
|
invoice.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
return invoice
|
|
|
|
|
|
async def delete_invoice(db: AsyncSession, invoice_id: uuid.UUID) -> bool:
|
|
invoice = await get_invoice(db, invoice_id)
|
|
if not invoice or invoice.status != "draft":
|
|
return False
|
|
await db.delete(invoice)
|
|
await db.commit()
|
|
return True
|
|
|
|
|
|
async def render_pdf(db: AsyncSession, invoice_id: uuid.UUID) -> str | None:
|
|
"""Generate PDF via WeasyPrint, upload to storage, return storage key."""
|
|
try:
|
|
from weasyprint import HTML
|
|
except ImportError:
|
|
logger.warning("WeasyPrint not installed — PDF generation skipped")
|
|
return None
|
|
|
|
invoice = await get_invoice(db, invoice_id)
|
|
if not invoice:
|
|
return None
|
|
|
|
html_content = _build_invoice_html(invoice)
|
|
pdf_bytes = HTML(string=html_content).write_pdf()
|
|
|
|
from app.core.storage import get_storage
|
|
storage = get_storage()
|
|
key = f"invoices/{invoice_id}.pdf"
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
tmp.write(pdf_bytes)
|
|
tmp_path = tmp.name
|
|
try:
|
|
storage.upload(tmp_path, key)
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
invoice.pdf_key = key
|
|
invoice.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
return key
|
|
|
|
|
|
def _build_invoice_html(invoice: Invoice) -> str:
|
|
lines_html = "".join(
|
|
f"<tr><td>{l.description}</td><td>{l.quantity}</td>"
|
|
f"<td>{l.unit_price or 0:.2f} {invoice.currency}</td>"
|
|
f"<td>{l.total or 0:.2f} {invoice.currency}</td></tr>"
|
|
for l in invoice.lines
|
|
)
|
|
return f"""<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; margin: 40px; color: #333; }}
|
|
h1 {{ color: #1a56db; }} table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
|
|
th, td {{ padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; }}
|
|
th {{ background: #f9fafb; font-weight: 600; }}
|
|
.totals {{ text-align: right; margin-top: 16px; }}
|
|
</style></head><body>
|
|
<h1>Invoice {invoice.invoice_number}</h1>
|
|
<p>Status: <strong>{invoice.status}</strong> | Currency: {invoice.currency}</p>
|
|
<p>Issued: {invoice.issued_at} | Due: {invoice.due_at or "—"}</p>
|
|
<table><thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Total</th></tr></thead>
|
|
<tbody>{lines_html}</tbody></table>
|
|
<div class="totals">
|
|
<p>Net: <strong>{invoice.total_net or 0:.2f} {invoice.currency}</strong></p>
|
|
<p>VAT ({float(invoice.vat_rate) * 100:.0f}%): {invoice.total_vat or 0:.2f} {invoice.currency}</p>
|
|
<p>Gross: <strong>{(invoice.total_net or 0) + (invoice.total_vat or 0):.2f} {invoice.currency}</strong></p>
|
|
</div>
|
|
{f'<p><em>Notes: {invoice.notes}</em></p>' if invoice.notes else ''}
|
|
</body></html>"""
|