@danhachuel/prodmaker 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/app/api/ads.py +3 -5
- package/app/api/billing.py +63 -0
- package/app/api/email_mkt.py +65 -0
- package/app/api/fraude.py +35 -0
- package/app/api/tiktok.py +16 -0
- package/app/main.py +25 -3
- package/app/services/database.py +63 -9
- package/app/services/dbengine.py +2 -1
- package/app/services/email_mkt.py +146 -0
- package/app/services/fraude.py +174 -0
- package/app/services/publish_ads.py +3 -2
- package/app/services/publish_tiktok.py +155 -0
- package/app/services/stripe_billing.py +147 -0
- package/app/services/vendas.py +14 -0
- package/frontend/dist/assets/index-B6BGGw5j.js +80 -0
- package/frontend/dist/assets/index-Feb3evdj.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/seed/planos.json +56 -0
- package/frontend/dist/assets/index-B4wxnTCs.css +0 -1
- package/frontend/dist/assets/index-tkOFAfR-.js +0 -77
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Anti-Fraude do AdsPixel — padrão do redis-datasets/fraud-detection:
|
|
2
|
+
IP blacklisting (Redis Cuckoo Filter) + click spamming (Redis Sorted Set).
|
|
3
|
+
|
|
4
|
+
- Redis é OPCIONAL: sem Redis, tudo cai em SQLite local (tabelas ips_bloqueados /
|
|
5
|
+
fraude_eventos) — bloqueio e detecção continuam funcionando.
|
|
6
|
+
- O pixel NUNCA quebra a página do visitante: avaliação em falha = segue normalmente.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .database import get_db, now_str, row_to_dict
|
|
13
|
+
from .llm import LLMError
|
|
14
|
+
from .store import get_settings
|
|
15
|
+
|
|
16
|
+
CUCKOO_KEY = "prodmaker:ip_blacklist_cf" # Redis Cuckoo Filter (IP_BLOCKED)
|
|
17
|
+
SPAM_KEY = "prodmaker:click_spamming" # Redis Sorted Set (IP -> timestamp)
|
|
18
|
+
SPAM_JANELA_SEGUNDOS = int(os.environ.get("PRODMAKER_SPAM_JANELA") or "60")
|
|
19
|
+
SPAM_LIMITE = int(os.environ.get("PRODMAKER_SPAM_LIMITE") or "10")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _redis_url() -> str | None:
|
|
23
|
+
env = (os.environ.get("REDIS_URL") or "").strip()
|
|
24
|
+
if env:
|
|
25
|
+
return env
|
|
26
|
+
keys = (get_settings().get("api_keys") or {}).get("redis") or {}
|
|
27
|
+
return (keys.get("url") or "").strip() or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _redis():
|
|
31
|
+
url = _redis_url()
|
|
32
|
+
if not url:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
import redis
|
|
36
|
+
|
|
37
|
+
client = redis.Redis.from_url(url, decode_responses=True, socket_timeout=2)
|
|
38
|
+
client.ping()
|
|
39
|
+
return client
|
|
40
|
+
except Exception:
|
|
41
|
+
return None # sem Redis: fallback SQLite
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def redis_ativo() -> bool:
|
|
45
|
+
return _redis() is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Blacklist (Cuckoo Filter quando há Redis; tabela local sempre)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _bloqueados_locais() -> dict[str, str]:
|
|
54
|
+
with get_db() as conn:
|
|
55
|
+
rows = conn.execute("SELECT ip, motivo FROM ips_bloqueados ORDER BY id DESC").fetchall()
|
|
56
|
+
return {row["ip"]: row["motivo"] for row in rows}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bloquear_ip(ip: str, motivo: str = "manual") -> dict:
|
|
60
|
+
ip = (ip or "").strip()
|
|
61
|
+
if not ip:
|
|
62
|
+
raise LLMError("Informe o IP a bloquear.")
|
|
63
|
+
client = _redis()
|
|
64
|
+
if client:
|
|
65
|
+
try:
|
|
66
|
+
client.execute_command("CF.ADDR", CUCKOO_KEY, ip)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass # filtro ausente no servidor: fica só local
|
|
69
|
+
with get_db() as conn:
|
|
70
|
+
existe = conn.execute("SELECT id FROM ips_bloqueados WHERE ip = ?", (ip,)).fetchone()
|
|
71
|
+
if not existe:
|
|
72
|
+
conn.execute(
|
|
73
|
+
"INSERT INTO ips_bloqueados (ip, motivo, origem, created_at) VALUES (?, ?, ?, ?)",
|
|
74
|
+
(ip, motivo, "redis" if client else "sqlite", now_str()),
|
|
75
|
+
)
|
|
76
|
+
return {"ok": True, "ip": ip, "engine": "redis" if client else "sqlite"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def desbloquear_ip(ip: str) -> None:
|
|
80
|
+
with get_db() as conn:
|
|
81
|
+
cursor = conn.execute("DELETE FROM ips_bloqueados WHERE ip = ?", (ip.strip(),))
|
|
82
|
+
if cursor.rowcount == 0:
|
|
83
|
+
raise LLMError("IP não encontrado na lista de bloqueio.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ip_bloqueado(ip: str) -> bool:
|
|
87
|
+
if not ip:
|
|
88
|
+
return False
|
|
89
|
+
client = _redis()
|
|
90
|
+
if client:
|
|
91
|
+
try:
|
|
92
|
+
resposta = client.execute_command("CF.EXISTS", CUCKOO_KEY, ip)
|
|
93
|
+
if isinstance(resposta, list):
|
|
94
|
+
resposta = resposta[0] if resposta else 0
|
|
95
|
+
if int(resposta):
|
|
96
|
+
return True
|
|
97
|
+
except Exception:
|
|
98
|
+
pass # redis sem o módulo RedisBloom: cai no local
|
|
99
|
+
return (ip or "").strip() in _bloqueados_locais()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Click spamming (Sorted Set quando há Redis)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def registrar_clique(ip: str, page: str = "") -> dict:
|
|
108
|
+
"""Registra um clique e devolve {spam: bool, cliques_janela: N}."""
|
|
109
|
+
ip = (ip or "").strip()
|
|
110
|
+
if not ip:
|
|
111
|
+
return {"spam": False, "cliques_janela": 0, "bloqueado": False}
|
|
112
|
+
agora = time.time()
|
|
113
|
+
cliques = 0
|
|
114
|
+
client = _redis()
|
|
115
|
+
if client:
|
|
116
|
+
try:
|
|
117
|
+
member = f"{int(agora * 1000)}:{ip}"
|
|
118
|
+
pipeline = client.pipeline()
|
|
119
|
+
pipeline.execute_command("ZADD", SPAM_KEY, agora, member)
|
|
120
|
+
pipeline.execute_command("ZREMRANGEBYSCORE", SPAM_KEY, 0, agora - SPAM_JANELA_SEGUNDOS)
|
|
121
|
+
pipeline.execute_command(
|
|
122
|
+
"ZCOUNT", SPAM_KEY, (int((agora - SPAM_JANELA_SEGUNDOS) * 1000)), int(agora * 1000)
|
|
123
|
+
)
|
|
124
|
+
cliques = int(pipeline.execute()[-1])
|
|
125
|
+
except Exception:
|
|
126
|
+
cliques = _cliques_local(ip, agora, page)
|
|
127
|
+
else:
|
|
128
|
+
cliques = _cliques_local(ip, agora, page)
|
|
129
|
+
spam = cliques >= SPAM_LIMITE
|
|
130
|
+
return {"spam": spam, "cliques_janela": cliques, "bloqueado": ip_bloqueado(ip)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cliques_local(ip: str, agora: float, page: str = "") -> int:
|
|
134
|
+
with get_db() as conn:
|
|
135
|
+
conn.execute("DELETE FROM fraude_eventos WHERE created_epoch < ?", (agora - SPAM_JANELA_SEGUNDOS * 2,))
|
|
136
|
+
conn.execute("INSERT INTO fraude_eventos (ip, page, created_epoch, created_at) VALUES (?, ?, ?, ?)", (ip, page or "", agora, now_str()))
|
|
137
|
+
row = conn.execute(
|
|
138
|
+
"SELECT COUNT(*) AS n FROM fraude_eventos WHERE ip = ? AND created_epoch > ?",
|
|
139
|
+
(ip, agora - SPAM_JANELA_SEGUNDOS),
|
|
140
|
+
).fetchone()
|
|
141
|
+
return int(row["n"])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Painel / API
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resumo() -> dict:
|
|
150
|
+
bloqueados = _bloqueados_locais()
|
|
151
|
+
spam_24h = 0
|
|
152
|
+
with get_db() as conn:
|
|
153
|
+
row = conn.execute(
|
|
154
|
+
"SELECT COUNT(*) AS n FROM fraude_eventos WHERE created_at >= datetime('now', '-1 day') GROUP BY ip HAVING COUNT(*) >= ?",
|
|
155
|
+
(SPAM_LIMITE,),
|
|
156
|
+
).fetchone()
|
|
157
|
+
spam_24h = row["n"] if row else 0
|
|
158
|
+
return {
|
|
159
|
+
"redis_ativo": redis_ativo(),
|
|
160
|
+
"ips_bloqueados": [
|
|
161
|
+
{"ip": ip, "motivo": motivo} for ip, motivo in bloqueados.items()
|
|
162
|
+
],
|
|
163
|
+
"total_bloqueados": len(bloqueados),
|
|
164
|
+
"spam_ultimas_24h": spam_24h,
|
|
165
|
+
"janela_segundos": SPAM_JANELA_SEGUNDOS,
|
|
166
|
+
"limite_cliques": SPAM_LIMITE,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def avaliar_visita(ip: str) -> dict:
|
|
171
|
+
"""Chamado pelo /track: decide se a visita entra no tracking."""
|
|
172
|
+
if ip_bloqueado(ip):
|
|
173
|
+
return {"bloqueado": True, "motivo": _bloqueados_locais().get((ip or "").strip(), "blacklist")}
|
|
174
|
+
return {"bloqueado": False}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Engines de publicação de campanhas — Google Ads (REST mutates)
|
|
1
|
+
"""Engines de publicação de campanhas — Google Ads (REST mutates), Meta Ads
|
|
2
|
+
(Marketing API) e TikTok Ads (Marketing API OAuth).
|
|
2
3
|
|
|
3
|
-
Best-effort com erros atribuídos por etapa.
|
|
4
|
+
Best-effort com erros atribuídos por etapa. Tudo é criado PAUSADO para revisão.
|
|
4
5
|
"""
|
|
5
6
|
import httpx
|
|
6
7
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""TikTok Ads — publicação automática via Marketing API (OAuth + campaign/adgroup).
|
|
2
|
+
|
|
3
|
+
Fluxo: auth_code (do portal TikTok) -> access_token (24h) -> refresh_token ->
|
|
4
|
+
campanha PAUSADA -> grupo de anúncios. O criativo (vídeo/imagem) exige upload de
|
|
5
|
+
arquivo local: sem vídeo informado, a campanha e o grupo são criados e o passo
|
|
6
|
+
final (anúncio) fica como instrução honesta — nenhum hack é inventado.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .llm import LLMError
|
|
13
|
+
from .store import get_settings, save_settings
|
|
14
|
+
|
|
15
|
+
TIKTOK_API = "https://business-api.tiktok.com/open_api/v1.3"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _keys() -> dict:
|
|
19
|
+
keys = (get_settings().get("api_keys") or {}).get("tiktok") or {}
|
|
20
|
+
return {
|
|
21
|
+
"app_id": (keys.get("app_id") or "").strip(),
|
|
22
|
+
"app_secret": (keys.get("app_secret") or "").strip(),
|
|
23
|
+
"auth_code": (keys.get("auth_code") or "").strip(),
|
|
24
|
+
"access_token": (keys.get("access_token") or "").strip(),
|
|
25
|
+
"refresh_token": (keys.get("refresh_token") or "").strip(),
|
|
26
|
+
"advertiser_id": (keys.get("advertiser_id") or "").strip(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check(required: list[str]) -> None:
|
|
31
|
+
config = _keys()
|
|
32
|
+
missing = [field for field in required if not config[field]]
|
|
33
|
+
if missing:
|
|
34
|
+
raise LLMError(
|
|
35
|
+
"TikTok Ads requer em Chaves de API: " + ", ".join(missing) + ".",
|
|
36
|
+
provider="tiktok",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def trocar_auth_code() -> dict:
|
|
41
|
+
"""Troca o auth_code (portal TikTok) por access_token + refresh_token."""
|
|
42
|
+
config = _keys()
|
|
43
|
+
_check(["app_id", "app_secret", "auth_code"])
|
|
44
|
+
try:
|
|
45
|
+
response = httpx.post(
|
|
46
|
+
f"{TIKTOK_API}/oauth2/access_token/",
|
|
47
|
+
json={"app_id": config["app_id"], "secret": config["app_secret"], "auth_code": config["auth_code"]},
|
|
48
|
+
timeout=30.0,
|
|
49
|
+
)
|
|
50
|
+
except httpx.HTTPError as exc:
|
|
51
|
+
raise LLMError(f"Falha de conexão com o TikTok: {exc}", provider="tiktok", transient=True) from exc
|
|
52
|
+
payload = response.json() or {}
|
|
53
|
+
if payload.get("code") != 0:
|
|
54
|
+
raise LLMError(
|
|
55
|
+
f"TikTok rejeitou o auth_code ({payload.get('code')}): {payload.get('message')} — gere um novo código no portal.",
|
|
56
|
+
provider="tiktok",
|
|
57
|
+
)
|
|
58
|
+
data = payload.get("data") or {}
|
|
59
|
+
access_token = data.get("access_token")
|
|
60
|
+
refresh_token = data.get("refresh_token")
|
|
61
|
+
advertiser_ids = data.get("advertiser_ids") or []
|
|
62
|
+
if not access_token:
|
|
63
|
+
raise LLMError("TikTok não devolveu access_token.", provider="tiktok")
|
|
64
|
+
|
|
65
|
+
settings = get_settings()
|
|
66
|
+
tiktok = settings.setdefault("api_keys", {}).setdefault("tiktok", {})
|
|
67
|
+
tiktok["access_token"] = access_token
|
|
68
|
+
tiktok["refresh_token"] = refresh_token
|
|
69
|
+
tiktok["connected_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
70
|
+
if advertiser_ids and not (tiktok.get("advertiser_id") or "").strip():
|
|
71
|
+
tiktok["advertiser_id"] = str(advertiser_ids[0])
|
|
72
|
+
save_settings(settings)
|
|
73
|
+
return {
|
|
74
|
+
"ok": True,
|
|
75
|
+
"connected_at": tiktok["connected_at"],
|
|
76
|
+
"advertiser_ids": advertiser_ids,
|
|
77
|
+
"advertiser_id": tiktok.get("advertiser_id"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _post(path: str, body: dict, token: str) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
response = httpx.post(
|
|
84
|
+
f"{TIKTOK_API}{path}",
|
|
85
|
+
headers={"Access-Token": token, "Content-Type": "application/json"},
|
|
86
|
+
json=body,
|
|
87
|
+
timeout=60.0,
|
|
88
|
+
)
|
|
89
|
+
except httpx.HTTPError as exc:
|
|
90
|
+
raise LLMError(f"Falha de conexão com o TikTok: {exc}", provider="tiktok", transient=True) from exc
|
|
91
|
+
payload = response.json() or {}
|
|
92
|
+
if payload.get("code") != 0:
|
|
93
|
+
raise LLMError(
|
|
94
|
+
f"TikTok API ({path}) {payload.get('code')}: {payload.get('message')}",
|
|
95
|
+
provider="tiktok",
|
|
96
|
+
)
|
|
97
|
+
return payload.get("data") or {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def publicar_campanha(campanha: dict) -> dict:
|
|
101
|
+
"""Cria campanha (PAUSADA) + grupo de anúncios. Criativo final = instrução honesta."""
|
|
102
|
+
config = _keys()
|
|
103
|
+
_check(["app_id", "app_secret", "advertiser_id", "access_token"])
|
|
104
|
+
plano = campanha.get("plano") or {}
|
|
105
|
+
nome = campanha["nome"]
|
|
106
|
+
orcamento_dia = float(campanha.get("orcamento_diario") or 0)
|
|
107
|
+
if orcamento_dia <= 0:
|
|
108
|
+
orcamento_dia = 15.0
|
|
109
|
+
budget_cents = int(orcamento_dia * 100)
|
|
110
|
+
|
|
111
|
+
etapas = []
|
|
112
|
+
campaign = _post(
|
|
113
|
+
"/campaign/create/",
|
|
114
|
+
{
|
|
115
|
+
"advertiser_id": config["advertiser_id"],
|
|
116
|
+
"campaign_name": nome,
|
|
117
|
+
"objective_type": "CONVERSIONS",
|
|
118
|
+
"budget_mode": "BUDGET_MODE_DAY",
|
|
119
|
+
"budget": budget_cents,
|
|
120
|
+
"operation_status": "DISABLE",
|
|
121
|
+
},
|
|
122
|
+
config["access_token"],
|
|
123
|
+
)
|
|
124
|
+
campaign_id = campaign.get("campaign_id")
|
|
125
|
+
etapas.append({"etapa": "campanha", "id": campaign_id, "status": "PAUSED"})
|
|
126
|
+
|
|
127
|
+
adgroup = _post(
|
|
128
|
+
"/adgroup/create/",
|
|
129
|
+
{
|
|
130
|
+
"advertiser_id": config["advertiser_id"],
|
|
131
|
+
"campaign_id": campaign_id,
|
|
132
|
+
"adgroup_name": f"{nome} — Grupo 1",
|
|
133
|
+
"placement_type": "PLACEMENT_TYPE_AUTOMATIC",
|
|
134
|
+
"budget_mode": "BUDGET_MODE_DAY",
|
|
135
|
+
"budget": budget_cents,
|
|
136
|
+
"schedule_type": "SCHEDULE_START_END",
|
|
137
|
+
"optimization_goal": "CONVERT",
|
|
138
|
+
"billing_event": "OCPM",
|
|
139
|
+
"operation_status": "DISABLE",
|
|
140
|
+
},
|
|
141
|
+
config["access_token"],
|
|
142
|
+
)
|
|
143
|
+
adgroup_id = adgroup.get("adgroup_id")
|
|
144
|
+
etapas.append({"etapa": "grupo", "id": adgroup_id, "status": "PAUSED"})
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"plataforma": "tiktok",
|
|
148
|
+
"etapas": etapas,
|
|
149
|
+
"campaign": campaign_id,
|
|
150
|
+
"adgroup": adgroup_id,
|
|
151
|
+
"observacao": (
|
|
152
|
+
"Campanha e grupo criados PAUSADOS. Passo final (anúncio): suba o vídeo vertical 9:16 "
|
|
153
|
+
"em TikTok Ads Manager ou use o upload de criativo — o ProdMaker avisa quando não há mídia informada."
|
|
154
|
+
),
|
|
155
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Stripe — planos, checkout sessions e webhooks de assinatura (infraestrutura pronta).
|
|
2
|
+
|
|
3
|
+
Modo local primeiro: sem chaves Stripe, o app segue 100% liberado (nenhuma trava).
|
|
4
|
+
Com stripe_secret_key + stripe_price_id configurados, o checkout e os webhooks
|
|
5
|
+
passam a operar. Verificação de assinatura do webhook (t=...,v1=...) via HMAC-SHA256.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .database import get_db, now_str, row_to_dict
|
|
16
|
+
from .llm import LLMError
|
|
17
|
+
from .store import get_settings, save_settings
|
|
18
|
+
|
|
19
|
+
STRIPE_API = "https://api.stripe.com/v1"
|
|
20
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _keys() -> dict:
|
|
24
|
+
keys = (get_settings().get("api_keys") or {}).get("stripe") or {}
|
|
25
|
+
return {
|
|
26
|
+
"secret_key": (keys.get("secret_key") or "").strip(),
|
|
27
|
+
"webhook_secret": (keys.get("webhook_secret") or "").strip(),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def planos_local() -> dict:
|
|
32
|
+
path = SEED_DIR / "planos.json"
|
|
33
|
+
if path.exists():
|
|
34
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
return {"planos": [], "nota": "seed/planos.json ausente."}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _plano(plano_id: str) -> dict | None:
|
|
39
|
+
for plano in planos_local().get("planos") or []:
|
|
40
|
+
if plano.get("id") == plano_id:
|
|
41
|
+
return plano
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def criar_checkout(plano_id: str, email: str, sucesso_url: str, cancelamento_url: str) -> dict:
|
|
46
|
+
config = _keys()
|
|
47
|
+
plano = _plano(plano_id)
|
|
48
|
+
if not plano:
|
|
49
|
+
raise LLMError(f"Plano '{plano_id}' não encontrado em seed/planos.json.")
|
|
50
|
+
price_id = (plano.get("stripe_price_id") or "").strip()
|
|
51
|
+
if not config["secret_key"] or not price_id:
|
|
52
|
+
raise LLMError(
|
|
53
|
+
"Assinatura requer Stripe configurado: stripe_secret_key e o stripe_price_id do plano "
|
|
54
|
+
"(em seed/planos.json / Chaves de API). Sem isso, o app segue liberado em modo local."
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
response = httpx.post(
|
|
58
|
+
f"{STRIPE_API}/checkout/sessions",
|
|
59
|
+
headers={"Authorization": f"Bearer {config['secret_key']}"},
|
|
60
|
+
data={
|
|
61
|
+
"mode": "subscription",
|
|
62
|
+
"customer_email": email,
|
|
63
|
+
"client_reference_id": plano_id,
|
|
64
|
+
"line_items[0][price]": price_id,
|
|
65
|
+
"line_items[0][quantity]": "1",
|
|
66
|
+
"success_url": sucesso_url,
|
|
67
|
+
"cancel_url": cancelamento_url,
|
|
68
|
+
},
|
|
69
|
+
timeout=30.0,
|
|
70
|
+
)
|
|
71
|
+
except httpx.HTTPError as exc:
|
|
72
|
+
raise LLMError(f"Falha de conexão com o Stripe: {exc}", provider="stripe", transient=True) from exc
|
|
73
|
+
payload = response.json() or {}
|
|
74
|
+
if response.status_code >= 400:
|
|
75
|
+
raise LLMError(
|
|
76
|
+
f"Stripe rejeitou o checkout ({response.status_code}): {(payload.get('error') or {}).get('message', payload)}",
|
|
77
|
+
provider="stripe",
|
|
78
|
+
)
|
|
79
|
+
_registrar(plano_id, email, "checkout_criado", payload.get("id", ""), payload)
|
|
80
|
+
return {"ok": True, "checkout_url": payload.get("url"), "session_id": payload.get("id")}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def verificar_assinatura(payload_body: bytes, cabecalho: str) -> bool:
|
|
84
|
+
"""Stripe-Signature: t=<ts>,v1=<hmac_sha256(secret, ts.body)>"""
|
|
85
|
+
secret = _keys()["webhook_secret"]
|
|
86
|
+
if not secret:
|
|
87
|
+
return True # sem segredo configurado não há como verificar localmente
|
|
88
|
+
partes = {}
|
|
89
|
+
for item in cabecalho.split(","):
|
|
90
|
+
key, _, valor = item.strip().partition("=")
|
|
91
|
+
partes[key] = valor
|
|
92
|
+
timestamp = partes.get("t", "")
|
|
93
|
+
assinatura = partes.get("v1", "")
|
|
94
|
+
if not timestamp or not assinatura:
|
|
95
|
+
return False
|
|
96
|
+
esperado = hmac.new(secret.encode(), f"{timestamp}.".encode() + payload_body, hashlib.sha256).hexdigest()
|
|
97
|
+
return hmac.compare_digest(esperado, assinatura)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def processar_webhook(evento: dict) -> dict:
|
|
101
|
+
tipo = evento.get("type", "")
|
|
102
|
+
objeto = (evento.get("data") or {}).get("object") or {}
|
|
103
|
+
if tipo == "checkout.session.completed":
|
|
104
|
+
plano_id = objeto.get("client_reference_id") or ""
|
|
105
|
+
email = (objeto.get("customer_details") or {}).get("email") or objeto.get("customer_email") or ""
|
|
106
|
+
_registrar(plano_id, email, "assinatura_ativa", objeto.get("subscription") or objeto.get("id", ""), objeto)
|
|
107
|
+
elif tipo in ("customer.subscription.deleted", "customer.subscription.paused"):
|
|
108
|
+
_registrar("", "", "assinatura_cancelada", objeto.get("id", ""), objeto)
|
|
109
|
+
elif tipo == "invoice.paid":
|
|
110
|
+
_registrar("", "", "pagamento_aprovado", objeto.get("id", ""), objeto)
|
|
111
|
+
return {"ok": True, "tipo": tipo}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _registrar(plano_id: str, email: str, status: str, stripe_ref: str, payload: dict) -> None:
|
|
115
|
+
with get_db() as conn:
|
|
116
|
+
if status == "assinatura_ativa":
|
|
117
|
+
conn.execute(
|
|
118
|
+
"INSERT INTO assinaturas (plano, email, stripe_ref, status, current_period_end, created_at) VALUES (?, ?, ?, 'ativa', ?, ?)",
|
|
119
|
+
(plano_id, email, stripe_ref, time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() + 30 * 86400)), now_str()),
|
|
120
|
+
)
|
|
121
|
+
elif status == "assinatura_cancelada":
|
|
122
|
+
conn.execute("UPDATE assinaturas SET status = 'cancelada' WHERE stripe_ref = ?", (stripe_ref,))
|
|
123
|
+
elif status == "checkout_criado":
|
|
124
|
+
conn.execute(
|
|
125
|
+
"INSERT INTO assinaturas (plano, email, stripe_ref, status, current_period_end, created_at) VALUES (?, ?, ?, 'checkout', '', ?)",
|
|
126
|
+
(plano_id, email, stripe_ref, now_str()),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def assinatura_atual() -> dict:
|
|
131
|
+
with get_db() as conn:
|
|
132
|
+
row = conn.execute("SELECT * FROM assinaturas WHERE status IN ('ativa','checkout') ORDER BY id DESC LIMIT 1").fetchone()
|
|
133
|
+
config = _keys()
|
|
134
|
+
base = {
|
|
135
|
+
"stripe_configurado": bool(config["secret_key"]),
|
|
136
|
+
"modo": "local" if not config["secret_key"] else "cloud",
|
|
137
|
+
"recursos_liberados": True, # regra: nenhuma trava de pré-registo no app local
|
|
138
|
+
}
|
|
139
|
+
if row:
|
|
140
|
+
base["assinatura"] = row_to_dict(row)
|
|
141
|
+
return base
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def historico(limit: int = 30) -> list[dict]:
|
|
145
|
+
with get_db() as conn:
|
|
146
|
+
rows = conn.execute("SELECT * FROM assinaturas ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
147
|
+
return [row_to_dict(row) for row in rows]
|
package/app/services/vendas.py
CHANGED
|
@@ -224,6 +224,20 @@ def registrar_venda(data: dict) -> dict:
|
|
|
224
224
|
f"{record.get('produto_nome') or 'Produto'} — R$ {record.get('valor')} ({record.get('fonte')})"
|
|
225
225
|
+ (f" · GCLID {record['gclid'][:20]}" if record.get("gclid") else ""),
|
|
226
226
|
)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
from . import email_mkt
|
|
230
|
+
|
|
231
|
+
email_mkt.aplicar_gatilho(
|
|
232
|
+
"venda_recebida",
|
|
233
|
+
{
|
|
234
|
+
"nome": (record.get("comprador_email") or "cliente").split("@")[0],
|
|
235
|
+
"produto": record.get("produto_nome") or "seu produto",
|
|
236
|
+
"valor": record.get("valor") or "",
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass # e-mail nunca quebra o registro da venda
|
|
227
241
|
return record
|
|
228
242
|
|
|
229
243
|
|