PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat): - US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero @font-face si zero /static/fonts/; landing aliniat la acelasi stack - US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat (invariant zero-silent-failures pastrat) - US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan; meniu burger cu separatoare; gate strict pe is_authenticated - US-011: selector tema pill icon+eticheta (reuse THEMES) - US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod operatii, cod ales se salveaza fara "+", Renunta inchide via closest) - US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni - fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR: - US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage, CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit) - US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil); valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch) - US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO pluralizat + banner one-time trial->Gratuit + pagina Cont Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat. Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18 (corpus kNN) ramane separat, necomis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
5.0 KiB
Python
131 lines
5.0 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.
|
|
|
|
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
|