generated from coulomb/repo-seed
324 lines
11 KiB
Python
324 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
PricingModelStatus = Literal["active", "candidate", "retired"]
|
|
ChargeComponentKind = Literal[
|
|
"access",
|
|
"setup",
|
|
"usage",
|
|
"support",
|
|
"discount",
|
|
"risk_adjustment",
|
|
]
|
|
ParameterClass = Literal[
|
|
"fixed",
|
|
"seller_controlled",
|
|
"customer_tunable",
|
|
"calculated",
|
|
"constrained",
|
|
"provider",
|
|
]
|
|
|
|
_ALLOWED_COMPONENT_KINDS = {
|
|
"access",
|
|
"setup",
|
|
"usage",
|
|
"support",
|
|
"discount",
|
|
"risk_adjustment",
|
|
}
|
|
_ALLOWED_PARAMETER_CLASSES = {
|
|
"fixed",
|
|
"seller_controlled",
|
|
"customer_tunable",
|
|
"calculated",
|
|
"constrained",
|
|
"provider",
|
|
}
|
|
|
|
|
|
def _money(value: str | int | float | Decimal | None) -> Decimal | None:
|
|
if value in (None, ""):
|
|
return None
|
|
return Decimal(str(value))
|
|
|
|
|
|
def _tuple_dict(value: dict[str, Any] | None) -> dict[str, Any]:
|
|
return dict(value or {})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChargeComponent:
|
|
id: str
|
|
kind: ChargeComponentKind | str
|
|
amount: Decimal | None = None
|
|
cadence: str | None = None
|
|
meter: str | None = None
|
|
unit: str | None = None
|
|
unit_price: Decimal | None = None
|
|
included_units: Decimal | None = None
|
|
label: str | None = None
|
|
billing_treatment: str | None = None
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Commitment:
|
|
id: str
|
|
kind: str
|
|
value: str
|
|
unit: str | None = None
|
|
description: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TunableParameter:
|
|
key: str
|
|
parameter_class: ParameterClass | str
|
|
data_type: str
|
|
description: str = ""
|
|
default_value: str | None = None
|
|
min_value: Decimal | None = None
|
|
max_value: Decimal | None = None
|
|
options: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PricingModel:
|
|
id: str
|
|
name: str
|
|
model_type: str
|
|
lifecycle_phase: str
|
|
currency: str
|
|
access_fee_amount: Decimal
|
|
access_fee_cadence: str
|
|
status: PricingModelStatus
|
|
description: str = ""
|
|
included_usage: str | None = None
|
|
overage_meter: str | None = None
|
|
charge_components: tuple[ChargeComponent, ...] = ()
|
|
commitments: tuple[Commitment, ...] = ()
|
|
tunable_parameters: tuple[TunableParameter, ...] = ()
|
|
eligibility: tuple[str, ...] = ()
|
|
provider_hints: dict[str, Any] = field(default_factory=dict)
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _parse_charge_component(raw: dict[str, Any]) -> ChargeComponent:
|
|
return ChargeComponent(
|
|
id=raw["id"],
|
|
kind=raw["kind"],
|
|
amount=_money(raw.get("amount")),
|
|
cadence=raw.get("cadence"),
|
|
meter=raw.get("meter"),
|
|
unit=raw.get("unit"),
|
|
unit_price=_money(raw.get("unit_price")),
|
|
included_units=_money(raw.get("included_units")),
|
|
label=raw.get("label"),
|
|
billing_treatment=raw.get("billing_treatment"),
|
|
metadata=_tuple_dict(raw.get("metadata")),
|
|
)
|
|
|
|
|
|
def _parse_commitment(raw: dict[str, Any]) -> Commitment:
|
|
return Commitment(
|
|
id=raw["id"],
|
|
kind=raw["kind"],
|
|
value=str(raw["value"]),
|
|
unit=raw.get("unit"),
|
|
description=raw.get("description", ""),
|
|
)
|
|
|
|
|
|
def _parse_tunable_parameter(raw: dict[str, Any]) -> TunableParameter:
|
|
return TunableParameter(
|
|
key=raw["key"],
|
|
parameter_class=raw["parameter_class"],
|
|
data_type=raw["data_type"],
|
|
description=raw.get("description", ""),
|
|
default_value=str(raw["default_value"]) if raw.get("default_value") is not None else None,
|
|
min_value=_money(raw.get("min_value")),
|
|
max_value=_money(raw.get("max_value")),
|
|
options=tuple(str(item) for item in raw.get("options", [])),
|
|
)
|
|
|
|
|
|
def _legacy_charge_components(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
|
components: list[dict[str, Any]] = [
|
|
{
|
|
"id": f"{raw['id']}-access",
|
|
"kind": "access",
|
|
"amount": raw["access_fee_amount"],
|
|
"cadence": raw["access_fee_cadence"],
|
|
"label": "Recurring access fee",
|
|
"billing_treatment": "recurring",
|
|
"metadata": {"included_usage": raw.get("included_usage")},
|
|
}
|
|
]
|
|
if raw.get("model_type") == "hybrid_subscription_usage" or raw.get("overage_meter"):
|
|
components.append(
|
|
{
|
|
"id": f"{raw['id']}-usage",
|
|
"kind": "usage",
|
|
"meter": raw.get("overage_meter") or "usage",
|
|
"unit": raw.get("unit") or "usage_unit",
|
|
"included_units": raw.get("included_units") or raw.get("included_tokens"),
|
|
"unit_price": raw.get("unit_price") or raw.get("overage_unit_price"),
|
|
"label": "Variable usage component",
|
|
"billing_treatment": "metered",
|
|
"metadata": {"included_usage": raw.get("included_usage")},
|
|
}
|
|
)
|
|
return components
|
|
|
|
|
|
def _access_component(components: tuple[ChargeComponent, ...]) -> ChargeComponent | None:
|
|
return next((component for component in components if component.kind == "access"), None)
|
|
|
|
|
|
def _usage_component(components: tuple[ChargeComponent, ...]) -> ChargeComponent | None:
|
|
return next((component for component in components if component.kind == "usage"), None)
|
|
|
|
|
|
def _parse_pricing_model(raw: dict[str, Any]) -> PricingModel:
|
|
charge_components = tuple(
|
|
_parse_charge_component(item)
|
|
for item in (raw.get("charge_components") or _legacy_charge_components(raw))
|
|
)
|
|
access_component = _access_component(charge_components)
|
|
usage_component = _usage_component(charge_components)
|
|
|
|
access_fee_amount = _money(raw.get("access_fee_amount"))
|
|
if access_fee_amount is None:
|
|
access_fee_amount = access_component.amount if access_component else Decimal("0")
|
|
|
|
access_fee_cadence = raw.get("access_fee_cadence")
|
|
if access_fee_cadence is None:
|
|
access_fee_cadence = access_component.cadence if access_component else "monthly"
|
|
|
|
metadata = _tuple_dict(raw.get("metadata"))
|
|
included_usage = raw.get("included_usage") or metadata.get("included_usage")
|
|
if included_usage is None and access_component:
|
|
included_usage = access_component.metadata.get("included_usage")
|
|
if included_usage is None and usage_component:
|
|
included_usage = usage_component.metadata.get("included_usage")
|
|
|
|
overage_meter = raw.get("overage_meter") or (usage_component.meter if usage_component else None)
|
|
|
|
return PricingModel(
|
|
id=raw["id"],
|
|
name=raw["name"],
|
|
model_type=raw["model_type"],
|
|
lifecycle_phase=raw["lifecycle_phase"],
|
|
currency=raw["currency"],
|
|
access_fee_amount=access_fee_amount or Decimal("0"),
|
|
access_fee_cadence=access_fee_cadence or "monthly",
|
|
status=raw["status"],
|
|
description=raw.get("description", ""),
|
|
included_usage=included_usage,
|
|
overage_meter=overage_meter,
|
|
charge_components=charge_components,
|
|
commitments=tuple(
|
|
_parse_commitment(item) for item in raw.get("commitments", [])
|
|
),
|
|
tunable_parameters=tuple(
|
|
_parse_tunable_parameter(item) for item in raw.get("tunable_parameters", [])
|
|
),
|
|
eligibility=tuple(str(item) for item in raw.get("eligibility", [])),
|
|
provider_hints=_tuple_dict(raw.get("provider_hints")),
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def load_pricing_models(path: str | Path) -> list[PricingModel]:
|
|
raw = _read_json(Path(path))
|
|
models = [_parse_pricing_model(item) for item in raw["models"]]
|
|
issues = validate_pricing_catalog(models)
|
|
if issues:
|
|
formatted = "; ".join(
|
|
f"{model_id}: {', '.join(model_issues)}"
|
|
for model_id, model_issues in sorted(issues.items())
|
|
)
|
|
raise ValueError(f"invalid pricing catalog: {formatted}")
|
|
return models
|
|
|
|
|
|
def validate_pricing_catalog(models: list[PricingModel]) -> dict[str, list[str]]:
|
|
issues: dict[str, list[str]] = {}
|
|
ids = [model.id for model in models]
|
|
if len(ids) != len(set(ids)):
|
|
issues.setdefault("__catalog__", []).append("duplicate model ids")
|
|
for model in models:
|
|
model_issues = validate_pricing_model(model)
|
|
if model_issues:
|
|
issues[model.id] = model_issues
|
|
return issues
|
|
|
|
|
|
def validate_pricing_model(model: PricingModel) -> list[str]:
|
|
issues: list[str] = []
|
|
|
|
if model.status not in {"active", "candidate", "retired"}:
|
|
issues.append(f"unsupported status '{model.status}'")
|
|
|
|
if model.access_fee_amount < Decimal("0"):
|
|
issues.append("access_fee_amount must be non-negative")
|
|
|
|
if not model.charge_components:
|
|
issues.append("at least one charge component is required")
|
|
|
|
component_ids = [component.id for component in model.charge_components]
|
|
if len(component_ids) != len(set(component_ids)):
|
|
issues.append("charge component ids must be unique")
|
|
|
|
access_components = [component for component in model.charge_components if component.kind == "access"]
|
|
if len(access_components) != 1:
|
|
issues.append("exactly one access charge component is required")
|
|
|
|
for component in model.charge_components:
|
|
if component.kind not in _ALLOWED_COMPONENT_KINDS:
|
|
issues.append(f"unsupported charge component kind '{component.kind}'")
|
|
if component.kind == "access":
|
|
if component.amount is None:
|
|
issues.append("access charge component must define amount")
|
|
if component.cadence is None:
|
|
issues.append("access charge component must define cadence")
|
|
if component.kind == "usage" and component.meter is None:
|
|
issues.append("usage charge component must define meter")
|
|
|
|
usage_components = [component for component in model.charge_components if component.kind == "usage"]
|
|
if model.model_type == "hybrid_subscription_usage" and not usage_components:
|
|
issues.append("hybrid_subscription_usage requires a usage charge component")
|
|
|
|
tunable_keys = [parameter.key for parameter in model.tunable_parameters]
|
|
if len(tunable_keys) != len(set(tunable_keys)):
|
|
issues.append("tunable parameter keys must be unique")
|
|
|
|
for parameter in model.tunable_parameters:
|
|
if parameter.parameter_class not in _ALLOWED_PARAMETER_CLASSES:
|
|
issues.append(f"unsupported parameter_class '{parameter.parameter_class}'")
|
|
if (
|
|
parameter.parameter_class == "customer_tunable"
|
|
and not parameter.options
|
|
and parameter.min_value is None
|
|
and parameter.max_value is None
|
|
):
|
|
issues.append(
|
|
f"customer_tunable parameter '{parameter.key}' must define bounds or options"
|
|
)
|
|
|
|
commitment_ids = [commitment.id for commitment in model.commitments]
|
|
if len(commitment_ids) != len(set(commitment_ids)):
|
|
issues.append("commitment ids must be unique")
|
|
|
|
return issues
|