"""Value objects: the states each lifecycle may be in, the attributes with a fixed set of values, and the
settings the customer edits (module.yaml config_rows).
Stored as their plain strings; naming them here makes a typo a NameError, not a new state.
"""
import re
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
class ProductState(str, Enum):
"""product: its lifecycle states (module.yaml workflows)"""
DRAFT = "draft"
ACTIVE = "active"
ARCHIVED = "archived"
class ProductKind(str, Enum):
"""product.kind: the values the spec allows"""
GOODS = "goods"
SERVICE = "service"
class ProductBillingKind(str, Enum):
"""product.billing_kind: the values the spec allows"""
ONE_OFF = "one_off"
RECURRING = "recurring"
METERED = "metered"
class ProductBillingPlan(str, Enum):
"""product.billing_plan: the values the spec allows"""
SINGLE = "single"
PERIODIC = "periodic"
ARREARS = "arrears"
class ProductInvoicingPolicy(str, Enum):
"""product.invoicing_policy: the values the spec allows"""
ORDERED = "ordered"
DELIVERED = "delivered"
class PriceListState(str, Enum):
"""price_list: its lifecycle states (module.yaml workflows)"""
DRAFT = "draft"
PUBLISHED = "published"
RETIRED = "retired"
class RowState(str, Enum):
"""A price row or a rule is DRAFT until its list (or its change) is published; only LIVE rows price anything."""
DRAFT = "draft"
LIVE = "live"
class RuleMethod(str, Enum):
"""price_rule.method (module.yaml): how a rule produces a price."""
FIXED = "fixed" # the rule's fixed_price
DISCOUNT_PCT = "discount_pct" # the base price less discount_pct percent
FORMULA = "formula" # the base price less discount_pct percent, plus surcharge_amount per unit
class RuleScope(str, Enum):
"""price_rule.applies_to (module.yaml)"""
PRODUCT = "product"
TEMPLATE = "template" # every variant of a product template
CATEGORY = "category"
ALL = "all"
# ── settings (module.yaml config_rows → prod_settings) ──────────────────────────────────────────────────────
# The controls on pricing authors: changed only with product:settings:controls, never by whoever they control.
CONTROL_KEYS = ("publish_needs_second_person", "unusual_price_pct", "business_timezone")
AI_STEPS = ("ai_check_duplicate", "ai_suggest_details", "ai_price_check")
CODE_PATTERN = re.compile(r"^(?P<pre>[A-Za-z0-9._/-]*)\{seq:(?P<width>[1-9])\}(?P<post>[A-Za-z0-9._/-]*)$")
@dataclass(frozen=True)
class Settings:
"""How this business keeps products and prices. A stored null is the reference behaviour (the default)."""
product_code_format: str = "typed"
publish_needs_second_person: bool = False
unusual_price_pct: int = 50
business_timezone: str = "Europe/London" # the IANA zone in which "today" is counted
ai_check_duplicate: str = "suggest"
ai_suggest_details: str = "suggest"
ai_price_check: str = "suggest"
@classmethod
def from_values(cls, values: dict) -> "Settings":
"""`values`: setting_key → stored value (None for the reference behaviour)."""
def pick(key: str, default):
v = values.get(key)
return default if v is None else v
d = cls()
return cls(
product_code_format=pick("product_code_format", d.product_code_format),
publish_needs_second_person=pick("publish_needs_second_person", d.publish_needs_second_person),
unusual_price_pct=pick("unusual_price_pct", d.unusual_price_pct),
business_timezone=pick("business_timezone", d.business_timezone),
**{s: pick(f"ai.{s}.mode", getattr(d, s)) for s in AI_STEPS})
def mode(self, step: str) -> str:
return getattr(self, step)
def check_setting(key: str, value: Any) -> Optional[str]:
"""Why `value` is not valid for this setting beyond its kind (the kind itself is the repository's check); None if fine."""
if value is None:
return None
if key == "product_code_format" and isinstance(value, str):
if value != "typed" and not CODE_PATTERN.match(value):
return "'typed', or a pattern with one counter such as PRD-{seq:5}"
if key == "business_timezone" and isinstance(value, str):
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError, OSError):
return "an IANA time zone such as Europe/London or America/New_York"
if key == "unusual_price_pct" and isinstance(value, int) and not isinstance(value, bool) and not (1 <= value <= 10000):
return "a percentage between 1 and 10000"
return None