from __future__ import annotations from decimal import Decimal from typing import Any from ._repo_root import ensure_repo_root_on_syspath from .boundary import build_boundary_policy from .models import EconomicsSnapshot, PricingModel ensure_repo_root_on_syspath() from adaptive_pricing_core.boundary_engine import PricingConfiguration # noqa: E402 from adaptive_pricing_core.comparable_ltv import ( # noqa: E402 ComparableCustomerProfile, LTVPolicy, SensitivityCase, compare_pricing_configurations, ) def _serialize(value: Any) -> Any: if isinstance(value, Decimal): return str(value) if hasattr(value, "__dataclass_fields__"): return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__} if isinstance(value, tuple): return [_serialize(item) for item in value] if isinstance(value, list): return [_serialize(item) for item in value] if isinstance(value, dict): return {key: _serialize(item) for key, item in value.items()} return value def _decimal(value: Decimal | str | int | float | None) -> Decimal: if value in (None, ""): return Decimal("0") return Decimal(str(value)) def _usage_component(model: PricingModel): return next((component for component in model.charge_components if component.kind == "usage"), None) def _included_units(model: PricingModel, members_per_customer: int) -> Decimal | None: usage = _usage_component(model) if not usage or usage.included_units is None: return None return usage.included_units * Decimal(members_per_customer) def _usage_unit_price(model: PricingModel) -> Decimal | None: usage = _usage_component(model) if not usage or usage.unit_price is None: return None return usage.unit_price def _usage_unit_cost(records: list[dict[str, Any]], period: str) -> Decimal: period_rows = [row for row in records if row.get("period") == period] total_units = sum(_decimal(row.get("tokens")) for row in period_rows) total_cost = sum(_decimal(row.get("cost_eur")) for row in period_rows) if total_units <= Decimal("0"): return Decimal("0") return total_cost / total_units def _payment_fee_rate(snapshot: EconomicsSnapshot) -> Decimal: if snapshot.monthly_revenue <= Decimal("0"): return Decimal("0") return (snapshot.monthly_payment_processing_cost / snapshot.monthly_revenue) * Decimal("100") def _profile(raw: dict[str, Any]) -> ComparableCustomerProfile: return ComparableCustomerProfile( id=raw["id"], name=raw["name"], segment=raw["segment"], eligible_model_ids=tuple(raw.get("eligible_model_ids", [])), members_per_customer=int(raw.get("members_per_customer", 1)), expected_monthly_usage_units=_decimal(raw.get("expected_monthly_usage_units")), usage_variance_pct=_decimal(raw.get("usage_variance_pct")), monthly_churn_pct=_decimal(raw.get("monthly_churn_pct")), monthly_default_pct=_decimal(raw.get("monthly_default_pct")), monthly_support_cost=_decimal(raw.get("monthly_support_cost")), monthly_risk_cost=_decimal(raw.get("monthly_risk_cost")), acquisition_cost=_decimal(raw.get("acquisition_cost")), upfront_investment_cost=_decimal(raw.get("upfront_investment_cost")), allocated_fixed_cost=_decimal(raw["allocated_fixed_cost"]) if raw.get("allocated_fixed_cost") else None, notes=raw.get("notes", ""), ) def _sensitivity_case(raw: dict[str, Any]) -> SensitivityCase: return SensitivityCase( id=raw["id"], name=raw["name"], usage_multiplier=_decimal(raw.get("usage_multiplier", "1")), usage_variance_delta_pct=_decimal(raw.get("usage_variance_delta_pct")), monthly_churn_delta_pct=_decimal(raw.get("monthly_churn_delta_pct")), monthly_default_delta_pct=_decimal(raw.get("monthly_default_delta_pct")), monthly_support_cost_delta=_decimal(raw.get("monthly_support_cost_delta")), monthly_risk_cost_delta=_decimal(raw.get("monthly_risk_cost_delta")), ) def _ltv_policy(raw: dict[str, Any]) -> LTVPolicy: return LTVPolicy( horizon_months=int(raw.get("horizon_months", 24)), monthly_discount_rate_pct=_decimal(raw.get("monthly_discount_rate_pct", "1.0")), required_improvement_factor=_decimal(raw.get("required_improvement_factor", "1.05")), ) def _configuration( model: PricingModel, profile: ComparableCustomerProfile, snapshot: EconomicsSnapshot, usage_unit_cost: Decimal, ) -> PricingConfiguration: members_per_customer = max(profile.members_per_customer, 1) per_member_fixed_cost = ( snapshot.monthly_infrastructure_cost / snapshot.active_members if snapshot.active_members else snapshot.monthly_infrastructure_cost ) allocated_fixed_cost = ( profile.allocated_fixed_cost if profile.allocated_fixed_cost is not None else per_member_fixed_cost * Decimal(members_per_customer) ) return PricingConfiguration( model=model, segment=profile.segment, expected_usage_units=profile.expected_monthly_usage_units, expected_usage_variance_pct=profile.usage_variance_pct, allocated_fixed_cost=allocated_fixed_cost, unit_cost=usage_unit_cost, support_cost=profile.monthly_support_cost, risk_cost=profile.monthly_risk_cost, payment_fee_rate_pct=_payment_fee_rate(snapshot), access_fee_amount=model.access_fee_amount * Decimal(members_per_customer), included_units=_included_units(model, members_per_customer), usage_unit_price=_usage_unit_price(model), ) def build_ltv_simulations( snapshot: EconomicsSnapshot, models: list[PricingModel], usage_records: list[dict[str, Any]], scenario_catalog: dict[str, Any], ) -> dict[str, Any]: policy = _ltv_policy(scenario_catalog) boundary_policy = build_boundary_policy(snapshot) sensitivity_cases = tuple(_sensitivity_case(item) for item in scenario_catalog.get("sensitivity_cases", [])) observed_usage_unit_cost = _usage_unit_cost(usage_records, snapshot.period) profile_results = [] for raw_profile in scenario_catalog.get("profiles", []): profile = _profile(raw_profile) configurations = [ _configuration(model, profile, snapshot, observed_usage_unit_cost) for model in models if model.status in ("active", "candidate") ] profile_results.append( compare_pricing_configurations( configurations, profile, boundary_policy, policy, sensitivity_cases=sensitivity_cases, ) ) primary = profile_results[0] if profile_results else None primary_scenarios = list(primary.comparisons) if primary else [] active_model = next((model for model in models if model.status == "active"), None) best_margin = max(primary_scenarios, key=lambda item: item.base_monthly_margin, default=None) best_ltv = max( primary_scenarios, key=lambda item: item.average_comparable_customer_lifetime_value, default=None, ) return _serialize({ "period": snapshot.period, "currency": snapshot.currency, "required_improvement_factor": policy.required_improvement_factor, "horizon_months": policy.horizon_months, "monthly_discount_rate_pct": policy.monthly_discount_rate_pct, "active_scenario_id": active_model.id if active_model else None, "best_margin_scenario_id": best_margin.model_id if best_margin else None, "best_ltv_scenario_id": best_ltv.model_id if best_ltv else None, "reference_model_id": primary.reference_model_id if primary else None, "primary_profile_id": primary.profile.id if primary else None, "scenarios": primary_scenarios, "profile_comparisons": profile_results, "calibration": { "observed_usage_unit_cost": observed_usage_unit_cost, "observed_payment_fee_rate_pct": _payment_fee_rate(snapshot), "profile_count": len(profile_results), }, "notes": [ scenario_catalog.get("notes", ""), "Primary scenarios expose the first configured comparable-customer profile for backward-compatible UI consumers.", "Profile comparisons compare candidate models using discounted seller LTV rather than only current-period gross margin.", ], })