File size: 4,713 Bytes
16e1aa7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | """
Central configuration for Etsy Listing Optimizer.
IMPORTANT: Tier pricing/limits live here (not scattered through the code) so
product changes don't require hunting through business logic. Prices are also
overridable via environment variables so pricing can change without a redeploy.
"""
import os
from dataclasses import dataclass
from enum import Enum
class Tier(str, Enum):
FREE = "free"
STARTER = "starter"
PRO = "pro"
BUSINESS = "business"
@dataclass(frozen=True)
class TierConfig:
tier: Tier
display_name: str
price_czk: int
generations_per_month: int # -1 = unlimited
title_variants: int
history_days: int # -1 = unlimited, 0 = none
brand_voice_profiles: int # -1 = unlimited
category_hints: bool
csv_export: bool
bulk_generation: bool
multi_shop: bool
stripe_price_env_var: str | None # env var name holding the Stripe Price ID
def _int_env(name: str, default: int) -> int:
val = os.environ.get(name)
if val is None or val == "":
return default
try:
return int(val)
except ValueError:
return default
# Prices are read from env vars first so they can be tuned without redeploying.
# Fall back to the defaults from the product brief if not set.
TIERS: dict[Tier, TierConfig] = {
Tier.FREE: TierConfig(
tier=Tier.FREE,
display_name="Free",
price_czk=0,
generations_per_month=_int_env("PRICE_FREE_GENERATIONS", 5),
title_variants=1,
history_days=0,
brand_voice_profiles=1,
category_hints=False,
csv_export=False,
bulk_generation=False,
multi_shop=False,
stripe_price_env_var=None,
),
Tier.STARTER: TierConfig(
tier=Tier.STARTER,
display_name="Starter",
price_czk=_int_env("PRICE_STARTER_CZK", 149),
generations_per_month=_int_env("PRICE_STARTER_GENERATIONS", 50),
title_variants=3,
history_days=30,
brand_voice_profiles=1,
category_hints=False,
csv_export=False,
bulk_generation=False,
multi_shop=False,
stripe_price_env_var="STRIPE_PRICE_STARTER",
),
Tier.PRO: TierConfig(
tier=Tier.PRO,
display_name="Pro",
price_czk=_int_env("PRICE_PRO_CZK", 349),
generations_per_month=-1,
title_variants=3,
history_days=-1,
brand_voice_profiles=5,
category_hints=True,
csv_export=True,
bulk_generation=False,
multi_shop=False,
stripe_price_env_var="STRIPE_PRICE_PRO",
),
Tier.BUSINESS: TierConfig(
tier=Tier.BUSINESS,
display_name="Business",
price_czk=_int_env("PRICE_BUSINESS_CZK", 799),
generations_per_month=-1,
title_variants=3,
history_days=-1,
brand_voice_profiles=-1,
category_hints=True,
csv_export=True,
bulk_generation=True,
multi_shop=True,
stripe_price_env_var="STRIPE_PRICE_BUSINESS",
),
}
def get_tier_config(tier: str | Tier) -> TierConfig:
if isinstance(tier, str):
tier = Tier(tier)
return TIERS[tier]
# ---------------------------------------------------------------------------
# Generation / validation constants
# ---------------------------------------------------------------------------
TITLE_MAX_CHARS = 140
TAG_MAX_CHARS = 20
TAG_COUNT = 13
MAX_VALIDATION_RETRIES = 2 # re-prompts after the first attempt
# Per-account hard rate limit on /generate, independent of the monthly quota.
# Prevents a single account from hammering the LLM provider in a burst.
GENERATE_RATE_LIMIT_PER_MINUTE = _int_env("GENERATE_RATE_LIMIT_PER_MINUTE", 6)
# Per-IP limit on auth endpoints (login/signup/password-reset).
AUTH_RATE_LIMIT_PER_MINUTE = _int_env("AUTH_RATE_LIMIT_PER_MINUTE", 10)
# ---------------------------------------------------------------------------
# Runtime / demo-mode flags
# ---------------------------------------------------------------------------
# If real credentials aren't configured yet, the app falls back to safe
# in-memory / mock implementations so it can still boot and be demoed.
# This is ONLY for local development and sandbox testing - never rely on
# this in production. See DEPLOY.md.
def _has(name: str) -> bool:
return bool(os.environ.get(name))
SUPABASE_CONFIGURED = _has("SUPABASE_URL") and _has("SUPABASE_SERVICE_ROLE_KEY")
GROQ_CONFIGURED = _has("GROQ_API_KEY")
GEMINI_CONFIGURED = _has("GEMINI_API_KEY")
STRIPE_CONFIGURED = _has("STRIPE_SECRET_KEY")
RESEND_CONFIGURED = _has("RESEND_API_KEY")
DEMO_MODE = not (SUPABASE_CONFIGURED and (GROQ_CONFIGURED or GEMINI_CONFIGURED) and STRIPE_CONFIGURED)
|