"""Pricing service — price lookup and order price computation. 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 decimal import Decimal from typing import Any from sqlalchemy import select, update as sql_update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.domains.billing.models import PricingTier 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