Curatare globala a comentariilor si docstring-urilor (app, tools, teste, scripturi): eliminate referintele la PRD-uri, US-xxx, task-uri istorice si review-uri; pastrata doar informatia functionala, formulata scurt. Regula adaugata in CLAUDE.md (sectiunea Stil). Fara modificari de cod sau comportament. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
"""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.
|
|
|
|
Note implementare:
|
|
- FREE_MONTHLY_LIMIT: constanta unica, tunabila fara arheologie de cod.
|
|
- CONSUMED_STATUSES: prestatie consumata = acceptata in coada.
|
|
- effective_tier: `now` injectabil pentru teste deterministe.
|
|
- monthly_usage: strftime localtime, `now` injectabil.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
|
|
# Limita lunara pentru planul Gratuit.
|
|
# O singura constanta, referita din PLANS. Tunabila fara a modifica logica de enforcement.
|
|
FREE_MONTHLY_LIMIT: int = 60
|
|
|
|
# Statusurile care consuma din cota lunara.
|
|
# 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:
|
|
# 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
|