"""Definitia planurilor de cont (sursa unica de adevar). Modul PUR, fara import DB/HTTP. Pattern ca app/errors.py: catalog + helperi. Consumat de rutele de ingestie si dashboard. Nu importa DB, HTTP, sau orice alt modul intern cu efecte secundare. Decizii implementare (PRD 5.17 / autoplan 2026-06-28): - FREE_MONTHLY_LIMIT: constanta unica (T-CEO-2), tunabila fara arqueologie de cod. - CONSUMED_STATUSES: decizie #20 — prestatie consumata = acceptata in coada. - effective_tier: `now` injectabil (decizie #2) pentru teste deterministe. - monthly_usage: pattern E7/5.15 (strftime localtime), `now` injectabil. """ from __future__ import annotations import sqlite3 from datetime import datetime, timezone # Limita lunara pentru planul Gratuit. # Decizie user T-CEO-2 (2026-06-28): o singura constanta, referita din PLANS. # Tunabila fara a modifica logica de enforcement. FREE_MONTHLY_LIMIT: int = 60 # Statusurile care consuma din cota lunara (decizie #20, 2026-06-28). # Prestatie consumata = acceptata in coada (queued/sending/sent), nu cele respinse/blocate. # Rationale: limita e pe ce trimitem la RAR, nu pe incercari esuate sau blocate. CONSUMED_STATUSES: tuple[str, ...] = ("queued", "sending", "sent") # Sursa unica de adevar pentru planuri. Fiecare plan are: # label -- eticheta afisata in RO (UI, mesaje) # monthly_limit -- None = nelimitat; int = limita prestatii/luna # api_access -- True = acces import prin API (/v1/*); False = doar web dashboard # # Aliniat landing-ului comercial (PRD 5.17 US-001): # Gratuit: 60/luna, fara API # Standard: nelimitat, fara API # Pro: nelimitat, cu API # Premium: nelimitat, cu API (suport dedicat) PLANS: dict[str, dict] = { "free": { "label": "Gratuit", "monthly_limit": FREE_MONTHLY_LIMIT, "api_access": False, }, "standard": { "label": "Standard", "monthly_limit": None, "api_access": False, }, "pro": { "label": "Pro", "monthly_limit": None, "api_access": True, }, "premium": { "label": "Premium", "monthly_limit": None, "api_access": True, }, } def effective_tier(account_row, now: datetime) -> str: """Returneaza tier-ul efectiv al contului la momentul `now` (injectabil pentru determinism). Daca `trial_until` e in viitor -> 'pro' (trial Pro activ). Altfel -> `tier`-ul de baza al contului. trial_until malformat/NULL -> fallback defensiv la tier de baza (nu arunca niciodata). `now` TREBUIE injectat explicit (nu datetime.now() intern) — decizie #2 din autoplan. Suporta sqlite3.Row si dict. """ # Citire robusta: suporta sqlite3.Row (IndexError pe key absent) si dict (KeyError) try: tier = account_row["tier"] except (KeyError, IndexError, TypeError): tier = "free" try: trial_until_str = account_row["trial_until"] except (KeyError, IndexError, TypeError): trial_until_str = None # Fallback defensiv la 'free' daca tier e None/gol if not tier: tier = "free" if not trial_until_str: return tier try: # Parseaza trial_until; stocam ca "YYYY-MM-DD HH:MM:SS" (UTC implicit) sau ISO tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T")) # Daca fara timezone -> assume UTC (cum stocam in DB) if tu.tzinfo is None: tu = tu.replace(tzinfo=timezone.utc) # Normalizeaza `now` la aware daca e naive now_cmp = now if now_cmp.tzinfo is None: now_cmp = now_cmp.replace(tzinfo=timezone.utc) if tu > now_cmp: return "pro" except (ValueError, AttributeError, TypeError): pass # malformat -> fallback defensiv la tier de baza return tier def monthly_usage(conn: sqlite3.Connection, account_id: int, now: datetime) -> int: """Numara prestatiile contului acceptate in coada in luna calendaristica curenta. Definitia 'luna curenta': strftime('%Y-%m', created_at, 'localtime') corespunde lunii lui `now` (acelasi pattern ca E7/5.15 din routes.py — consistent cu 'localtime'). `now` injectabil pentru teste deterministe. Scoped strict pe account_id. created_at NULL/malformat -> exclus defensiv (nu arunca niciodata). NOTA: containerul are /etc/localtime=UTC, deci 'localtime' = UTC in mediul de test. Testele de granita construiesc timestamp-uri relative la luna curenta calculata cu acelasi 'localtime', nu valori absolute care presupun +2/+3h. """ # Formatam `now` ca string SQLite si folosim acelasi modificator 'localtime' ca routes.py now_str = now.strftime("%Y-%m-%d %H:%M:%S") placeholders = ",".join("?" * len(CONSUMED_STATUSES)) row = conn.execute( f"SELECT COUNT(*) AS n FROM submissions " f"WHERE account_id = ? " f" AND status IN ({placeholders}) " f" AND created_at IS NOT NULL " f" AND strftime('%Y-%m', created_at, 'localtime') = strftime('%Y-%m', ?, 'localtime')", (account_id, *CONSUMED_STATUSES, now_str), ).fetchone() return int(row["n"]) if row else 0