"""Storefront product → license tier mapping. The mapping lives in ``server/config/products.yaml`` (gitignored for secrets it isn't — it's a routine catalog file) so adding a new SKU is one yaml edit plus a container restart. The lookup is ``(source, product_id) -> (tier, years)``. Cached at module import. The runtime cost of reloading on every webhook would be trivial, but caching keeps the hot path allocation-free and makes the "edit yaml, restart api" idiom explicit — operators always know exactly when their changes go live. """ from __future__ import annotations from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import Optional import yaml @dataclass(frozen=True) class ProductMapping: tier: str years: int def _config_path() -> Path: """Resolve the products config. Container layout puts the config at ``/app/config/products.yaml`` (the Dockerfile COPYs ``server/config`` to ``/app/config``). For local pytest runs we walk up from this file to ``server/``. """ in_container = Path("/app/config/products.yaml") if in_container.exists(): return in_container return Path(__file__).resolve().parent.parent / "config" / "products.yaml" @lru_cache(maxsize=1) def _table() -> dict[tuple[str, str], ProductMapping]: raw = yaml.safe_load(_config_path().read_text(encoding="utf-8")) or {} table: dict[tuple[str, str], ProductMapping] = {} for source, entries in raw.items(): for entry in entries or []: key = (source, str(entry["product_id"])) table[key] = ProductMapping( tier=entry["tier"], years=int(entry.get("years", 1)), ) return table def lookup(source: str, product_id: str) -> Optional[ProductMapping]: """Return the mapping for *(source, product_id)*, or None if unmapped. Returning None (rather than raising) lets the webhook layer decide whether to surface the failure as an audit row vs a user-visible error — we want unmapped sales to be logged, not to crash the handler and trigger Gumroad retry storms. """ return _table().get((source, product_id)) def reload_for_tests() -> None: """Drop the cache. Tests that mutate the yaml call this.""" _table.cache_clear()