"""Schema for the rate card — types only, no rates. Rates live in :mod:`genesyscalc.ratecard` as an immutable verbatim anchor. This module defines what a metered feature *is*, so the anchor can be typed and the arithmetic can be tested without either knowing the other's business. The invariants in ``__post_init__`` are the point: a meter flagged :attr:`Confidence.CONFIRMED` that carries no source URL and date cannot be constructed. That is what stops an unsourced number wearing a green tick on a client-facing stage. """ from __future__ import annotations from dataclasses import dataclass from enum import Enum class Confidence(Enum): """How much weight a number carries. Rendered on stage as an icon.""" CONFIRMED = "confirmed" # published by the vendor, with a source URL + date ESTIMATED = "estimated" # working assumption, stated UNKNOWN = "unknown" # not sourced; bound it with sensitivity if material @property def icon(self) -> str: return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value] class LicenceModel(Enum): """Genesys prices per-user AI features differently by licence model.""" NAMED = "named" CONCURRENT = "concurrent" @property def label(self) -> str: return self.value.capitalize() class MeterBasis(Enum): """How a feature converts activity into tokens.""" PER_USER_PER_MONTH = "per_user_per_month" # Copilot 40/60, STA 30/45 UNITS_PER_TOKEN = "units_per_token" # "51 sessions per token" TOKENS_PER_UNIT = "tokens_per_unit" # "0.5 tokens per interaction" VOICE_MINUTES = "voice_minutes" # 17 min/token, 15s per-call round-up ZERO_RATED = "zero_rated" # "no charge for token usage" class Tier(Enum): """Ordering for the published highest-tier rule. "Genesys bases charges on the highest tier (price) resource that Genesys Cloud uses during the interaction." Higher value = higher tier = wins. An interaction is charged once, at the highest tier it touched — see :class:`genesyscalc.usage.DeflectionMix`. """ BOT = 1 VIRTUAL_AGENT = 2 AGENTIC_VIRTUAL_AGENT = 3 @dataclass(frozen=True) class Meter: """One line of the vendor's published rate card. ``feature`` and ``published_rate`` hold the article's *exact* wording; ``rate`` holds the parsed number the arithmetic uses. Keeping both is what makes a transcription slip catchable — ``test_rate_card`` pins the strings, and a separate test proves the float agrees with the string. """ key: str feature: str # the article's exact feature name published_rate: str # the article's exact rate wording basis: MeterBasis rate: float = 0.0 # tokens per unit (0.0 for per-user/zero-rated) per_user_named: float | None = None per_user_concurrent: float | None = None tier: Tier | None = None confidence: Confidence = Confidence.CONFIRMED source_url: str | None = None source_date: str | None = None unit_label: str = "units" note: str = "" def __post_init__(self) -> None: if self.rate < 0: raise ValueError(f"{self.key}: rate must not be negative") if self.confidence is Confidence.CONFIRMED and not ( self.source_url and self.source_date ): raise ValueError( f"{self.key}: a confirmed (🟢) meter must carry a source URL and date" ) per_user = self.basis is MeterBasis.PER_USER_PER_MONTH has_pair = ( self.per_user_named is not None and self.per_user_concurrent is not None ) if per_user != has_pair: raise ValueError( f"{self.key}: per-user rates are required for " f"{MeterBasis.PER_USER_PER_MONTH.value} and forbidden otherwise" ) if self.basis is MeterBasis.ZERO_RATED and self.rate != 0.0: raise ValueError(f"{self.key}: a zero-rated meter must have rate 0.0") def tokens_per_user_month(self, licence: LicenceModel) -> float: """Subscription tokens for one user for one month.""" if self.basis is not MeterBasis.PER_USER_PER_MONTH: raise ValueError(f"{self.key} is not a per-user meter") rate = ( self.per_user_named if licence is LicenceModel.NAMED else self.per_user_concurrent ) assert rate is not None # guaranteed by __post_init__ return rate def tokens_for(self, units: float) -> float: """Consumption tokens for ``units`` of activity. Voice-bot minutes are NOT priced here — they carry a per-call round-up that needs call counts, so :func:`genesyscalc.usage.voice_bot_tokens` owns that path. """ if self.basis in (MeterBasis.ZERO_RATED,): return 0.0 if self.basis is MeterBasis.PER_USER_PER_MONTH: raise ValueError(f"{self.key}: use tokens_per_user_month()") return units * self.rate @dataclass(frozen=True) class TokenPrice: """What one token costs, list versus negotiated. ``contracted_rate`` (a signed per-token rate) wins outright when set. Otherwise the concession is a percentage off list — the lever a negotiation actually moves. Both are client data and arrive from the notebook's ``engagement-data`` cell, never from the anchor. """ currency: str = "USD" list_rate: float = 1.00 contracted_rate: float | None = None concession_pct: float = 0.0 def __post_init__(self) -> None: if self.list_rate <= 0: raise ValueError("list_rate must be positive") if not 0.0 <= self.concession_pct < 1.0: raise ValueError("concession_pct must be in [0, 1)") if self.contracted_rate is not None and self.contracted_rate < 0: raise ValueError("contracted_rate must not be negative") def effective_rate(self) -> float: """The rate actually billed per token.""" if self.contracted_rate is not None: return self.contracted_rate return self.list_rate * (1.0 - self.concession_pct)