@danhachuel/prodmaker 0.1.7 → 0.1.9

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,324 @@
1
+ """Minhas Vendas — webhooks de plataformas, entrada manual, CSV e matching por GCLID.
2
+
3
+ O parser de webhooks é TOLERANTE: percorre o JSON recebido procurando os campos
4
+ conhecidos (valor, comissão, e-mail, transação, gclid, produto, status), porque cada
5
+ plataforma (Hotmart, ClickBank, Digistore24, Kiify, Cakto) usa nomes diferentes.
6
+ Nada é inventado: campo ausente fica vazio.
7
+ """
8
+ import csv
9
+ import io
10
+ import json
11
+ from decimal import Decimal, InvalidOperation
12
+
13
+ from .database import get_db, now_str, row_to_dict
14
+ from .llm import LLMError
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Parser tolerante de webhooks
19
+ # ---------------------------------------------------------------------------
20
+
21
+ STATUS_MAP = {
22
+ # Hotmart / genéricos
23
+ "purchase_approved": "aprovada", "approved": "aprovada", "complete": "aprovada",
24
+ "completed": "aprovada", "paid": "aprovada", "settled": "aprovada", "cbcheck": "aprovada",
25
+ "purchase_refunded": "reembolsada", "refunded": "reembolsada", "refund": "reembolsada",
26
+ "chargeback": "reembolsada",
27
+ "purchase_delayed": "pendente", "pending": "pendente", "billet_printed": "pendente",
28
+ "waiting_payment": "pendente", "protest": "pendente",
29
+ "canceled": "cancelada", "cancelled": "cancelada",
30
+ }
31
+
32
+ VALOR_KEYS = ("valor", "value", "amount", "price", "total", "order_total")
33
+ COMISSAO_KEYS = ("comissao", "commission", "affiliate_value", "expected", "net")
34
+ PRODUTO_KEYS = ("produto", "product", "product_name", "item", "productname", "nome_produto")
35
+ EMAIL_KEYS = ("email", "buyer_email", "customer_email", "comprador_email", "buyer")
36
+ TRANSACAO_KEYS = ("transacao", "transaction", "transaction_id", "txid", "order", "receipt", "orderid", "pedido", "invoice")
37
+ GCLID_KEYS = ("gclid", "gbraid", "wbraid", "google_click_id", "gad_source")
38
+ UTM_KEYS = {"utm_source": "utm_source", "utm_medium": "utm_medium", "utm_campaign": "utm_campaign", "src": "utm_source"}
39
+ STATUS_KEYS = ("status", "event", "type", "situation", "transaction_type", "state")
40
+
41
+
42
+ def _walk(obj, keys, exclude_context=()):
43
+ """Busca recursiva pelos campos encontrados (case/acentos insensíveis),
44
+ com exclusão por contexto ancestral (ex.: ignorar 'value' dentro de 'commission').
45
+ Valores dict/list também são percorridos, para achar chaves aninhadas."""
46
+ wanted = {self_normalize(k) for k in keys}
47
+ excluded = {self_normalize(k) for k in exclude_context}
48
+ found = []
49
+ stack = [(obj, ())]
50
+ while stack:
51
+ current, context = stack.pop()
52
+ if isinstance(current, dict):
53
+ for key, value in current.items():
54
+ key_norm = self_normalize(key)
55
+ new_context = context + (key_norm,)
56
+ if key_norm in wanted and not (excluded and (excluded & set(context))):
57
+ found.append(value)
58
+ stack.append((value, new_context))
59
+ elif isinstance(current, list):
60
+ for item in current:
61
+ stack.append((item, context))
62
+ return found
63
+
64
+
65
+ def _walk_numbers_by_context(obj, context_terms=()):
66
+ """Números cujo caminho de chaves ancestrais contém algum dos termos
67
+ (ex.: comissão Hotmart em purchase.commission.value)."""
68
+ terms = {self_normalize(term) for term in context_terms}
69
+ found = []
70
+ stack = [(obj, ())]
71
+ while stack:
72
+ current, context = stack.pop()
73
+ if isinstance(current, dict):
74
+ for key, value in current.items():
75
+ stack.append((value, context + (self_normalize(key),)))
76
+ elif isinstance(current, list):
77
+ for item in current:
78
+ stack.append((item, context))
79
+ elif isinstance(current, (int, float)) and not isinstance(current, bool):
80
+ if terms & set(context):
81
+ found.append(float(current))
82
+ elif isinstance(current, str) and ";" not in current:
83
+ parsed = _to_float(current)
84
+ if parsed > 0 and terms & set(context):
85
+ found.append(parsed)
86
+ return found
87
+
88
+
89
+ def self_normalize(text: str) -> str:
90
+ return "".join(char for char in (text or "").lower() if not char in " -_")
91
+
92
+
93
+ def _to_float(value) -> float:
94
+ """Converte para float aceitando formatos BR (1.297,00 / 49,90) e US (197.00)."""
95
+ if value is None:
96
+ return 0.0
97
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
98
+ return float(value)
99
+ text = str(value).replace("R$", "").strip()
100
+ if "," in text and "." in text:
101
+ text = text.replace(".", "").replace(",", ".") # 1.297,00 (BR)
102
+ elif "," in text:
103
+ text = text.replace(",", ".") # 49,90 (BR decimal)
104
+ elif text.count(".") >= 2:
105
+ text = text.replace(".", "") # 1.297.00 (BR sem vírgula)
106
+ # só um ponto: formato US decimal (197.00) — mantido
107
+ try:
108
+ return float(Decimal(text))
109
+ except (InvalidOperation, ValueError):
110
+ return 0.0
111
+
112
+
113
+ def _first_str(values, default=""):
114
+ for value in values:
115
+ if isinstance(value, str) and value.strip():
116
+ return value.strip()
117
+ if isinstance(value, dict):
118
+ for nested_key in ("name", "nome", "title"):
119
+ nested = value.get(nested_key)
120
+ if isinstance(nested, str) and nested.strip():
121
+ return nested.strip()
122
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
123
+ return str(value)
124
+ return default
125
+
126
+
127
+ def _first_float(values) -> float:
128
+ for value in values:
129
+ result = _to_float(value)
130
+ if result > 0:
131
+ return result
132
+ return 0.0
133
+
134
+
135
+ def _status_from(values: list) -> str:
136
+ for value in values:
137
+ if isinstance(value, str):
138
+ return STATUS_MAP.get(self_normalize(value), "aprovada")
139
+ return "aprovada"
140
+
141
+
142
+ COMISSAO_CONTEXT = ("comissao", "commission", "expected", "affiliate", "coproducer", "comission")
143
+
144
+
145
+ def parse_webhook(fonte: str, payload: dict) -> dict:
146
+ """Extrai uma venda de um payload de webhook de forma tolerante."""
147
+ valor_candidates = _walk(payload, VALOR_KEYS, exclude_context=COMISSAO_CONTEXT)
148
+ comissao_candidates = _walk_numbers_by_context(payload, COMISSAO_CONTEXT)
149
+ if not comissao_candidates:
150
+ comissao_candidates = _walk(payload, ("expected", "comissao", "commission"))
151
+ gclid = _first_str(_walk(payload, GCLID_KEYS))
152
+ utm_source = _first_str(_walk(payload, ("utm_source", "src")))
153
+ utm_medium = _first_str(_walk(payload, ("utm_medium",)))
154
+ utm_campaign = _first_str(_walk(payload, ("utm_campaign", "campaign", "cname")))
155
+ return {
156
+ "fonte": fonte,
157
+ "produto_nome": _first_str(_walk(payload, PRODUTO_KEYS)),
158
+ "transacao_id": _first_str(_walk(payload, TRANSACAO_KEYS)),
159
+ "comprador_email": _first_str(_walk(payload, EMAIL_KEYS)),
160
+ "valor": _first_float(valor_candidates),
161
+ "comissao": _first_float(comissao_candidates),
162
+ "gclid": gclid,
163
+ "utm_source": utm_source,
164
+ "utm_medium": utm_medium,
165
+ "utm_campaign": utm_campaign,
166
+ "status": _status_from(_walk(payload, STATUS_KEYS)),
167
+ "raw": json.dumps(payload, ensure_ascii=False)[:4000],
168
+ }
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Registration, matching e leitura
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def _match_gclid(gclid: str) -> str:
177
+ if not gclid:
178
+ return ""
179
+ with get_db() as conn:
180
+ row = conn.execute(
181
+ "SELECT page, ip, created_at, ref, utm_source FROM visitas WHERE gclid = ? AND event = 'pageview' ORDER BY id DESC LIMIT 1",
182
+ (gclid,),
183
+ ).fetchone()
184
+ if not row:
185
+ return ""
186
+ match = dict(row)
187
+ match["matched"] = True
188
+ return json.dumps(match, ensure_ascii=False)
189
+
190
+
191
+ def registrar_venda(data: dict) -> dict:
192
+ timestamp = now_str()
193
+ gclid = data.get("gclid") or ""
194
+ with get_db() as conn:
195
+ cursor = conn.execute(
196
+ "INSERT INTO vendas (fonte, produto_id, produto_nome, transacao_id, comprador_email, valor, comissao, gclid, utm_source, utm_medium, utm_campaign, status, conversao_status, gclid_match, raw, created_at) "
197
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pendente', ?, ?, ?)",
198
+ (
199
+ data.get("fonte", "manual"),
200
+ data.get("produto_id"),
201
+ data.get("produto_nome", ""),
202
+ data.get("transacao_id", ""),
203
+ data.get("comprador_email", ""),
204
+ float(data.get("valor") or 0),
205
+ float(data.get("comissao") or 0),
206
+ gclid,
207
+ data.get("utm_source", ""),
208
+ data.get("utm_medium", ""),
209
+ data.get("utm_campaign", ""),
210
+ data.get("status", "aprovada"),
211
+ _match_gclid(gclid),
212
+ data.get("raw", ""),
213
+ timestamp,
214
+ ),
215
+ )
216
+ row = conn.execute("SELECT * FROM vendas WHERE id = ?", (cursor.lastrowid,)).fetchone()
217
+ return row_to_dict(row)
218
+
219
+
220
+ def list_vendas(limit: int = 300) -> list[dict]:
221
+ with get_db() as conn:
222
+ rows = conn.execute("SELECT * FROM vendas ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
223
+ return [row_to_dict(row) for row in rows]
224
+
225
+
226
+ def resumo() -> dict:
227
+ with get_db() as conn:
228
+ aprovadas = conn.execute("SELECT COUNT(*) AS n, COALESCE(SUM(valor),0) AS receita, COALESCE(SUM(comissao),0) AS comissao FROM vendas WHERE status = 'aprovada'").fetchone()
229
+ por_status = {row["status"]: row["n"] for row in conn.execute("SELECT status, COUNT(*) AS n FROM vendas GROUP BY status")}
230
+ por_fonte = [{"fonte": row["fonte"], "n": row["n"]} for row in conn.execute("SELECT fonte, COUNT(*) AS n FROM vendas GROUP BY fonte ORDER BY n DESC")]
231
+ por_dia = [{"dia": row["dia"], "receita": row["receita"], "vendas": row["n"]} for row in conn.execute("SELECT substr(created_at,1,10) AS dia, COUNT(*) AS n, COALESCE(SUM(valor),0) AS receita FROM vendas WHERE status = 'aprovada' GROUP BY dia ORDER BY dia")]
232
+ por_produto = [{"produto": row["produto_nome"] or "(sem nome)", "n": row["n"], "receita": row["receita"]} for row in conn.execute("SELECT produto_nome, COUNT(*) AS n, COALESCE(SUM(valor),0) AS receita FROM vendas WHERE status = 'aprovada' GROUP BY produto_nome ORDER BY receita DESC")]
233
+ conv = {row["conversao_status"]: row["n"] for row in conn.execute("SELECT conversao_status, COUNT(*) AS n FROM vendas GROUP BY conversao_status")}
234
+ conversoes = {
235
+ "pendentes": conv.get("pendente", 0),
236
+ "enviadas": conv.get("enviada", 0),
237
+ "erros": conv.get("erro", 0),
238
+ }
239
+ return {
240
+ "vendas": aprovadas["n"],
241
+ "receita": round(aprovadas["receita"], 2),
242
+ "comissao": round(aprovadas["comissao"], 2),
243
+ "por_status": por_status,
244
+ "por_fonte": por_fonte,
245
+ "por_dia": por_dia,
246
+ "por_produto": por_produto,
247
+ "conversoes": conversoes,
248
+ }
249
+
250
+
251
+ def delete_venda(venda_id: int) -> None:
252
+ with get_db() as conn:
253
+ cursor = conn.execute("DELETE FROM vendas WHERE id = ?", (venda_id,))
254
+ if cursor.rowcount == 0:
255
+ raise LLMError("Venda não encontrada.")
256
+
257
+
258
+ def get_venda(venda_id: int) -> dict:
259
+ with get_db() as conn:
260
+ row = conn.execute("SELECT * FROM vendas WHERE id = ?", (venda_id,)).fetchone()
261
+ if not row:
262
+ raise LLMError("Venda não encontrada.")
263
+ return row_to_dict(row)
264
+
265
+
266
+ def marcar_conversao(venda_id: int, status: str, erro: str = "") -> None:
267
+ with get_db() as conn:
268
+ conn.execute("UPDATE vendas SET conversao_status = ?, conversao_erro = ? WHERE id = ?", (status, erro[:2000], venda_id))
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # CSV
273
+ # ---------------------------------------------------------------------------
274
+
275
+ COLUMN_ALIASES = {
276
+ "produto": "produto_nome", "product": "produto_nome", "produto_nome": "produto_nome", "item": "produto_nome",
277
+ "valor": "valor", "value": "valor", "amount": "valor", "price": "valor", "preco": "valor",
278
+ "comissao": "comissao", "commission": "comissao", "comissao_r": "comissao",
279
+ "gclid": "gclid", "google_click_id": "gclid",
280
+ "email": "comprador_email", "comprador_email": "comprador_email",
281
+ "transacao": "transacao_id", "transaction": "transacao_id", "transacao_id": "transacao_id", "pedido": "transacao_id",
282
+ "utm_source": "utm_source", "utm_medium": "utm_medium", "utm_campaign": "utm_campaign",
283
+ }
284
+
285
+
286
+ def importar_csv(texto: str) -> dict:
287
+ """Importa vendas de CSV com cabeçalho (ordem livre). Retorna contagem e erros por linha."""
288
+ imported = 0
289
+ errors = []
290
+ reader = csv.DictReader(io.StringIO(texto))
291
+ if not reader.fieldnames:
292
+ raise LLMError("CSV vazio — envie um cabeçalho com colunas como produto, valor, comissao, gclid, email.")
293
+ mapping = {}
294
+ for column in reader.fieldnames:
295
+ alias = COLUMN_ALIASES.get(self_normalize(column))
296
+ if alias:
297
+ mapping[alias] = column
298
+ if "valor" not in mapping:
299
+ raise LLMError("CSV sem coluna de valor reconhecível (procure por: valor, value, amount, price).")
300
+ for index, row in enumerate(reader, start=1):
301
+ data = {}
302
+ for alias, column in mapping.items():
303
+ data[alias] = (row.get(column) or "").strip()
304
+ if not data.get("produto_nome") and not data.get("valor"):
305
+ continue
306
+ try:
307
+ payload = {
308
+ "fonte": "csv",
309
+ "produto_nome": data.get("produto_nome", ""),
310
+ "valor": _to_float(data.get("valor")),
311
+ "comissao": _to_float(data.get("comissao")),
312
+ "gclid": data.get("gclid", ""),
313
+ "comprador_email": data.get("comprador_email", ""),
314
+ "transacao_id": data.get("transacao_id", ""),
315
+ "utm_source": data.get("utm_source", ""),
316
+ "utm_medium": data.get("utm_medium", ""),
317
+ "utm_campaign": data.get("utm_campaign", ""),
318
+ "status": "aprovada",
319
+ }
320
+ registrar_venda(payload)
321
+ imported += 1
322
+ except Exception as exc: # noqa: BLE001
323
+ errors.append(f"Linha {index}: {exc}")
324
+ return {"importadas": imported, "erros": errors}