@danhachuel/prodmaker 0.1.15 → 0.1.17

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.
@@ -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