@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 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.15"
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.15"}
275
+ return {"version": APP_VERSION, "canonical": "0.1.16"}
272
276
 
273
277
 
274
278
  @app.get("/api/settings")
@@ -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())
@@ -31,7 +31,8 @@ def row_to_dict(row) -> dict:
31
31
 
32
32
 
33
33
  def dsn() -> str | None:
34
- return (os.environ.get("DATABASE_URL") or "").strip() or None
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]
@@ -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