@danhachuel/prodmaker 0.1.15 → 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 +20 -0
- package/app/api/billing.py +63 -0
- package/app/api/email_mkt.py +65 -0
- package/app/main.py +6 -2
- package/app/services/database.py +36 -0
- package/app/services/dbengine.py +2 -1
- package/app/services/email_mkt.py +146 -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-rK4wl-bk.js +0 -78
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
|
|
4
4
|
(o registry NPM normaliza `0.1.00` → `0.1.0`).
|
|
5
5
|
|
|
6
|
+
## 0.1.16 — 2026-10-31
|
|
7
|
+
|
|
8
|
+
- **Fase 12 — Billing Stripe + E-mail Marketing (AdsAcademy) + Supabase multi-tenant pronto.**
|
|
9
|
+
- **Billing:** `seed/planos.json` (Local grátis / Lançador PRO R$97 / Escala AGENCY R$297),
|
|
10
|
+
página **Plano & Assinatura** com cards, e-mail de conta e botão Assinar (abre o
|
|
11
|
+
Checkout do Stripe em nova aba); `POST /api/billing/checkout`, `/api/billing/assinatura`,
|
|
12
|
+
`/api/billing/historico` e **webhook público** `/api/billing/webhook` com verificação
|
|
13
|
+
de assinatura HMAC-SHA256 (t=...,v1=...) processando checkout.session.completed,
|
|
14
|
+
invoice.paid e customer.subscription.deleted. Sem chaves Stripe o app segue 100%
|
|
15
|
+
liberado em modo local (nenhuma trava de pré-registo).
|
|
16
|
+
- **E-mail Marketing (AdsAcademy):** sequências com gatilho (`venda_recebida` dispara
|
|
17
|
+
automaticamente a cada webhook de venda, `produto_salvo`, `manual`), variáveis
|
|
18
|
+
{{nome}}/{{produto}}/{{valor}}/{{link}}, envio pelo apprise já integrado (mailtos://,
|
|
19
|
+
tgram://...), editor de sequências, envio manual e histórico com status/erro. A
|
|
20
|
+
plataforma completa emareach fica como backlog da nuvem.
|
|
21
|
+
- **Supabase multi-tenant:** `DATABASE_URL` agora também aceita o alias **`SUPABASE_DB_URL`**
|
|
22
|
+
(connection string do Supabase) mantendo SQLite como fallback — user_id já cobre as
|
|
23
|
+
15 tabelas; a ativação do multi-tenant depende apenas do Supabase Auth (backlog).
|
|
24
|
+
- Novas tabelas: `assinaturas`, `email_sequencias`, `email_envios`.
|
|
25
|
+
|
|
6
26
|
## 0.1.15 — 2026-10-31
|
|
7
27
|
|
|
8
28
|
- **Fase 11 — TikTok Ads real + Anti-Fraude AdsPixel (Redis).**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Billing — planos (seed), checkout Stripe, webhook público e status da assinatura."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
3
|
+
from fastapi.responses import Response
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from ..services import stripe_billing
|
|
7
|
+
from ..services.llm import LLMError
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CheckoutBody(BaseModel):
|
|
13
|
+
plano_id: str
|
|
14
|
+
email: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/planos")
|
|
18
|
+
def planos():
|
|
19
|
+
return stripe_billing.planos_local()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/assinatura")
|
|
23
|
+
def assinatura():
|
|
24
|
+
return stripe_billing.assinatura_atual()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/historico")
|
|
28
|
+
def historico():
|
|
29
|
+
return stripe_billing.historico()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/checkout")
|
|
33
|
+
def checkout(body: CheckoutBody, request: Request):
|
|
34
|
+
base = f"{request.url.scheme}://{request.headers.get('host', 'localhost:8188')}"
|
|
35
|
+
try:
|
|
36
|
+
return stripe_billing.criar_checkout(
|
|
37
|
+
body.plano_id,
|
|
38
|
+
body.email,
|
|
39
|
+
f"{base}/configuracoes?checkout=ok",
|
|
40
|
+
f"{base}/configuracoes?checkout=cancelado",
|
|
41
|
+
)
|
|
42
|
+
except LLMError as exc:
|
|
43
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.options("/webhook")
|
|
47
|
+
def webhook_options():
|
|
48
|
+
return Response(status_code=204)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/webhook")
|
|
52
|
+
async def webhook_stripe(request: Request):
|
|
53
|
+
"""Webhook público do Stripe — verifica assinatura e processa o evento."""
|
|
54
|
+
body = await request.body()
|
|
55
|
+
if not stripe_billing.verificar_assinatura(body, request.headers.get("stripe-signature", "")):
|
|
56
|
+
raise HTTPException(status_code=400, detail="Assinatura do webhook inválida.")
|
|
57
|
+
import json
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
evento = json.loads(body)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
raise HTTPException(status_code=400, detail="Payload inválido.")
|
|
63
|
+
return stripe_billing.processar_webhook(evento)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""E-mail Marketing (AdsAcademy) — sequências com gatilho, envio manual e histórico."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..services import email_mkt
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/email", tags=["email"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SequenciaBody(BaseModel):
|
|
12
|
+
nome: str
|
|
13
|
+
gatilho: str
|
|
14
|
+
assunto: str
|
|
15
|
+
corpo: str
|
|
16
|
+
ativo: bool = True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnviarBody(BaseModel):
|
|
20
|
+
sequencia_id: int
|
|
21
|
+
destinos: list = []
|
|
22
|
+
variaveis: dict = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/sequencias")
|
|
26
|
+
def sequencias():
|
|
27
|
+
return email_mkt.listar()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.post("/sequencias")
|
|
31
|
+
def criar(body: SequenciaBody):
|
|
32
|
+
try:
|
|
33
|
+
return email_mkt.salvar(body.nome, body.gatilho, body.assunto, body.corpo, body.ativo)
|
|
34
|
+
except LLMError as exc:
|
|
35
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.put("/sequencias/{sequencia_id}")
|
|
39
|
+
def atualizar(sequencia_id: int, body: SequenciaBody):
|
|
40
|
+
try:
|
|
41
|
+
return email_mkt.salvar(body.nome, body.gatilho, body.assunto, body.corpo, body.ativo, sequencia_id)
|
|
42
|
+
except LLMError as exc:
|
|
43
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.delete("/sequencias/{sequencia_id}")
|
|
47
|
+
def remover(sequencia_id: int):
|
|
48
|
+
try:
|
|
49
|
+
email_mkt.remover(sequencia_id)
|
|
50
|
+
except LLMError as exc:
|
|
51
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.post("/enviar")
|
|
56
|
+
def enviar(body: EnviarBody):
|
|
57
|
+
try:
|
|
58
|
+
return email_mkt.enviar_manual(body.sequencia_id, body.destinos, body.variaveis)
|
|
59
|
+
except LLMError as exc:
|
|
60
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/historico")
|
|
64
|
+
def historico():
|
|
65
|
+
return email_mkt.historico()
|
package/app/main.py
CHANGED
|
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
|
|
|
15
15
|
from .services.llm import LLMError, analyze_product, list_providers
|
|
16
16
|
from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_master_code, get_settings, save_settings
|
|
17
17
|
|
|
18
|
-
APP_VERSION = "0.1.
|
|
18
|
+
APP_VERSION = "0.1.16"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -43,6 +43,8 @@ from .api import invest as invest_router # noqa: E402
|
|
|
43
43
|
from .api import builder as builder_router # noqa: E402
|
|
44
44
|
from .api import fraude as fraude_router # noqa: E402
|
|
45
45
|
from .api import tiktok as tiktok_router # noqa: E402
|
|
46
|
+
from .api import billing as billing_router # noqa: E402
|
|
47
|
+
from .api import email_mkt as email_mkt_router # noqa: E402
|
|
46
48
|
|
|
47
49
|
app.include_router(llm_router.router)
|
|
48
50
|
app.include_router(infoprodutos_router.router)
|
|
@@ -62,6 +64,8 @@ app.include_router(invest_router.router)
|
|
|
62
64
|
app.include_router(builder_router.router)
|
|
63
65
|
app.include_router(fraude_router.router)
|
|
64
66
|
app.include_router(tiktok_router.router)
|
|
67
|
+
app.include_router(billing_router.router)
|
|
68
|
+
app.include_router(email_mkt_router.router)
|
|
65
69
|
|
|
66
70
|
ensure_db()
|
|
67
71
|
|
|
@@ -268,7 +272,7 @@ def health():
|
|
|
268
272
|
|
|
269
273
|
@app.get("/api/version")
|
|
270
274
|
def version():
|
|
271
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
275
|
+
return {"version": APP_VERSION, "canonical": "0.1.16"}
|
|
272
276
|
|
|
273
277
|
|
|
274
278
|
@app.get("/api/settings")
|
package/app/services/database.py
CHANGED
|
@@ -221,6 +221,42 @@ TABLES = {
|
|
|
221
221
|
created_at TEXT NOT NULL
|
|
222
222
|
)
|
|
223
223
|
""",
|
|
224
|
+
"assinaturas": """
|
|
225
|
+
CREATE TABLE IF NOT EXISTS assinaturas (
|
|
226
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
227
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
228
|
+
plano TEXT NOT NULL DEFAULT '',
|
|
229
|
+
email TEXT NOT NULL DEFAULT '',
|
|
230
|
+
stripe_ref TEXT NOT NULL DEFAULT '',
|
|
231
|
+
status TEXT NOT NULL DEFAULT 'checkout',
|
|
232
|
+
current_period_end TEXT NOT NULL DEFAULT '',
|
|
233
|
+
created_at TEXT NOT NULL
|
|
234
|
+
)
|
|
235
|
+
""",
|
|
236
|
+
"email_sequencias": """
|
|
237
|
+
CREATE TABLE IF NOT EXISTS email_sequencias (
|
|
238
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
239
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
240
|
+
nome TEXT NOT NULL,
|
|
241
|
+
gatilho TEXT NOT NULL DEFAULT 'manual',
|
|
242
|
+
assunto TEXT NOT NULL,
|
|
243
|
+
corpo TEXT NOT NULL,
|
|
244
|
+
ativo INTEGER NOT NULL DEFAULT 1,
|
|
245
|
+
created_at TEXT NOT NULL
|
|
246
|
+
)
|
|
247
|
+
""",
|
|
248
|
+
"email_envios": """
|
|
249
|
+
CREATE TABLE IF NOT EXISTS email_envios (
|
|
250
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
251
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
252
|
+
sequencia_id INTEGER NOT NULL DEFAULT 0,
|
|
253
|
+
destino TEXT NOT NULL DEFAULT '',
|
|
254
|
+
assunto TEXT NOT NULL DEFAULT '',
|
|
255
|
+
status TEXT NOT NULL DEFAULT 'pendente',
|
|
256
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
257
|
+
created_at TEXT NOT NULL
|
|
258
|
+
)
|
|
259
|
+
""",
|
|
224
260
|
}
|
|
225
261
|
|
|
226
262
|
ALL_TABLES = list(TABLES.keys())
|
package/app/services/dbengine.py
CHANGED
|
@@ -31,7 +31,8 @@ def row_to_dict(row) -> dict:
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def dsn() -> str | None:
|
|
34
|
-
|
|
34
|
+
"""Connection string do PostgreSQL — aceita DATABASE_URL ou SUPABASE_DB_URL."""
|
|
35
|
+
return (os.environ.get("DATABASE_URL") or os.environ.get("SUPABASE_DB_URL") or "").strip() or None
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
def is_postgres() -> bool:
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""E-mail Marketing / AdsAcademy — sequências transacionais via apprise.
|
|
2
|
+
|
|
3
|
+
Emareach (plataforma completa self-hosted) fica como backlog da nuvem; aqui o
|
|
4
|
+
motor honesto é o apprise já integrado: sequências com gatilho (venda recebida,
|
|
5
|
+
produto salvo, manual), variáveis {{nome}}, {{produto}}, {{valor}}, {{link}} e
|
|
6
|
+
histórico de envios. Sem canais configurados: erro claro, nunca silencioso.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .database import get_db, now_str, row_to_dict
|
|
11
|
+
from .llm import LLMError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _render(texto: str, variaveis: dict) -> str:
|
|
15
|
+
for chave, valor in (variaveis or {}).items():
|
|
16
|
+
texto = texto.replace("{{" + chave + "}}", str(valor or ""))
|
|
17
|
+
return texto
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def listar(gatilho: str = "") -> list[dict]:
|
|
21
|
+
query = "SELECT * FROM email_sequencias"
|
|
22
|
+
params: tuple = ()
|
|
23
|
+
if gatilho:
|
|
24
|
+
query += " WHERE gatilho = ?"
|
|
25
|
+
params = (gatilho,)
|
|
26
|
+
query += " ORDER BY id DESC"
|
|
27
|
+
with get_db() as conn:
|
|
28
|
+
rows = conn.execute(query, params).fetchall()
|
|
29
|
+
return [row_to_dict(row) for row in rows]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def salvar(nome: str, gatilho: str, assunto: str, corpo: str, ativo: bool = True, sequencia_id: int | None = None) -> dict:
|
|
33
|
+
if not nome.strip() or not assunto.strip() or not corpo.strip():
|
|
34
|
+
raise LLMError("Informe nome, assunto e corpo da sequência.")
|
|
35
|
+
if gatilho not in ("venda_recebida", "produto_salvo", "manual"):
|
|
36
|
+
raise LLMError("Gatilho inválido: use venda_recebida, produto_salvo ou manual.")
|
|
37
|
+
with get_db() as conn:
|
|
38
|
+
if sequencia_id:
|
|
39
|
+
cursor = conn.execute(
|
|
40
|
+
"UPDATE email_sequencias SET nome = ?, gatilho = ?, assunto = ?, corpo = ?, ativo = ? WHERE id = ?",
|
|
41
|
+
(nome.strip(), gatilho, assunto.strip(), corpo, bool(ativo), sequencia_id),
|
|
42
|
+
)
|
|
43
|
+
if cursor.rowcount == 0:
|
|
44
|
+
raise LLMError("Sequência não encontrada.")
|
|
45
|
+
else:
|
|
46
|
+
cursor = conn.execute(
|
|
47
|
+
"INSERT INTO email_sequencias (nome, gatilho, assunto, corpo, ativo, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
48
|
+
(nome.strip(), gatilho, assunto.strip(), corpo, bool(ativo), now_str()),
|
|
49
|
+
)
|
|
50
|
+
sequencia_id = cursor.lastrowid
|
|
51
|
+
row = conn.execute("SELECT * FROM email_sequencias WHERE id = ?", (sequencia_id,)).fetchone()
|
|
52
|
+
return row_to_dict(row)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remover(sequencia_id: int) -> None:
|
|
56
|
+
with get_db() as conn:
|
|
57
|
+
cursor = conn.execute("DELETE FROM email_sequencias WHERE id = ?", (sequencia_id,))
|
|
58
|
+
if cursor.rowcount == 0:
|
|
59
|
+
raise LLMError("Sequência não encontrada.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _variaveis_padrao() -> dict:
|
|
63
|
+
settings_nome = "cliente"
|
|
64
|
+
return {"nome": settings_nome, "produto": "seu produto", "valor": "", "link": ""}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def aplicar_gatilho(gatilho: str, variaveis: dict, destinos: list[str] | None = None) -> dict:
|
|
68
|
+
"""Dispara as sequências ativas do gatilho (usado pelos hooks de venda/produto)."""
|
|
69
|
+
enviados = []
|
|
70
|
+
erros = []
|
|
71
|
+
for sequencia in listar(gatilho):
|
|
72
|
+
if not sequencia["ativo"]:
|
|
73
|
+
continue
|
|
74
|
+
assunto = _render(sequencia["assunto"], variaveis)
|
|
75
|
+
corpo = _render(sequencia["corpo"], variaveis)
|
|
76
|
+
try:
|
|
77
|
+
_enviar(sequencia, destinos or [], assunto, corpo)
|
|
78
|
+
enviados.append(sequencia["id"])
|
|
79
|
+
except LLMError as exc:
|
|
80
|
+
_registrar_envio(sequencia["id"], "", assunto, "erro", str(exc))
|
|
81
|
+
erros.append(str(exc))
|
|
82
|
+
return {"enviados": enviados, "erros": erros}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def enviar_manual(sequencia_id: int, destinos: list[str], variaveis: dict) -> dict:
|
|
86
|
+
with get_db() as conn:
|
|
87
|
+
row = conn.execute("SELECT * FROM email_sequencias WHERE id = ?", (sequencia_id,)).fetchone()
|
|
88
|
+
if not row:
|
|
89
|
+
raise LLMError("Sequência não encontrada.")
|
|
90
|
+
sequencia = row_to_dict(row)
|
|
91
|
+
if not destinos:
|
|
92
|
+
raise LLMError("Informe ao menos um destino (URL apprise, ex.: mailtos://user:senha@smtp.com).")
|
|
93
|
+
assunto = _render(sequencia["assunto"], {**_variaveis_padrao(), **(variaveis or {})})
|
|
94
|
+
corpo = _render(sequencia["corpo"], {**_variaveis_padrao(), **(variaveis or {})})
|
|
95
|
+
_enviar(sequencia, destinos, assunto, corpo)
|
|
96
|
+
return {"ok": True, "assunto": assunto}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _enviar(sequencia: dict, destinos: list, assunto: str, corpo: str) -> None:
|
|
100
|
+
from . import notify
|
|
101
|
+
|
|
102
|
+
alvos = [str(item).strip() for item in (destinos or []) if str(item).strip()]
|
|
103
|
+
if not alvos:
|
|
104
|
+
config = notify.listar_config()
|
|
105
|
+
alvos = [item for item in (config.get("targets") or [])]
|
|
106
|
+
if not alvos:
|
|
107
|
+
erro = "Nenhum canal apprise configurado (settings.notifications.targets)."
|
|
108
|
+
_registrar_envio(sequencia["id"], "", assunto, "erro", erro)
|
|
109
|
+
raise LLMError(erro)
|
|
110
|
+
telegram = notify._telegram_target()
|
|
111
|
+
if telegram and telegram not in alvos:
|
|
112
|
+
alvos.insert(0, telegram)
|
|
113
|
+
try:
|
|
114
|
+
import apprise
|
|
115
|
+
|
|
116
|
+
apobj = apprise.Apprise()
|
|
117
|
+
validos = [alvo for alvo in alvos if apobj.add(alvo)]
|
|
118
|
+
if not validos:
|
|
119
|
+
erro = "Nenhum canal apprise válido."
|
|
120
|
+
_registrar_envio(sequencia["id"], ",".join(alvos), assunto, "erro", erro)
|
|
121
|
+
raise LLMError(erro)
|
|
122
|
+
if apobj.notify(title=assunto, body=corpo):
|
|
123
|
+
_registrar_envio(sequencia["id"], ",".join(validos), assunto, "enviada")
|
|
124
|
+
else:
|
|
125
|
+
erro = "apprise não conseguiu entregar."
|
|
126
|
+
_registrar_envio(sequencia["id"], ",".join(validos), assunto, "erro", erro)
|
|
127
|
+
raise LLMError(erro)
|
|
128
|
+
except ImportError as exc:
|
|
129
|
+
erro = f"apprise ausente ({exc.name}) — rode 'prodmaker install'."
|
|
130
|
+
_registrar_envio(sequencia["id"], "", assunto, "erro", erro)
|
|
131
|
+
raise LLMError(erro)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _registrar_envio(sequencia_id: int, destino: str, assunto: str, status: str, erro: str = "") -> None:
|
|
135
|
+
with get_db() as conn:
|
|
136
|
+
conn.execute(
|
|
137
|
+
"INSERT INTO email_envios (sequencia_id, destino, assunto, status, erro, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
138
|
+
(sequencia_id, destino, assunto[:180], status, erro[:500], now_str()),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def historico(limit: int = 50) -> dict:
|
|
143
|
+
with get_db() as conn:
|
|
144
|
+
envios = [row_to_dict(row) for row in conn.execute("SELECT * FROM email_envios ORDER BY id DESC LIMIT ?", (limit,))]
|
|
145
|
+
totais = {row["status"]: row["n"] for row in conn.execute("SELECT status, COUNT(*) AS n FROM email_envios GROUP BY status")}
|
|
146
|
+
return {"envios": envios, "totais": totais}
|
|
@@ -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
|
|