package main // bot_payments.go — payment polling, webhook processing, and idempotent delivery. import ( "fmt" "log" "strconv" "strings" "time" ) // ---------- Polling mode ---------- func (b *Bot) runPaymentPoller() { d, err := time.ParseDuration(b.cfg.MPPollInterval) if err != nil || d < 5*time.Second { d = 20 * time.Second } t := time.NewTicker(d) defer t.Stop() log.Printf("[bot] payment poller started (interval=%s)", d) for { select { case <-b.ctx.Done(): log.Printf("[bot] payment poller stopped") return case <-t.C: b.pollPending() } } } func (b *Bot) pollPending() { txns, err := b.store.ListPendingTransactions(b.ctx) if err != nil { log.Printf("[bot] poll list: %v", err) return } now := time.Now() for _, txn := range txns { if txn.ExpiresAt != nil && now.After(*txn.ExpiresAt) { _ = b.store.SetTransactionStatus(b.ctx, txn.ID, "expired") b.notify(txn.TelegramID, fmt.Sprintf("⌛ O PIX do pedido #%d expirou. Gere um novo pagamento se ainda quiser.", txn.ID)) continue } if b.mp == nil { continue } status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID) if err != nil { continue } if status == "approved" { b.tryFulfill(txn.ID) } } } // ---------- Webhook mode ---------- // processPaymentByMPID is invoked from the Mercado Pago webhook handler. func (b *Bot) processPaymentByMPID(mpID string) { txn, err := b.store.GetTransactionByMPID(b.ctx, mpID) if err != nil { log.Printf("[bot] webhook: no txn for mp payment %s: %v", mpID, err) return } if b.mp == nil { return } status, err := b.mp.GetPaymentStatus(b.ctx, mpID) if err != nil { log.Printf("[bot] webhook: get status %s: %v", mpID, err) return } if status == "approved" { b.tryFulfill(txn.ID) } } // ---------- Idempotent fulfillment ---------- // tryFulfill flips the txn to approved exactly once, then delivers. func (b *Bot) tryFulfill(txnID int) { ok, err := b.store.MarkTransactionApproved(b.ctx, txnID) if err != nil { log.Printf("[bot] mark approved %d: %v", txnID, err) return } if !ok { return // already fulfilled by another path } txn, err := b.store.GetTransaction(b.ctx, txnID) if err != nil { log.Printf("[bot] fulfill get txn %d: %v", txnID, err) return } b.fulfillTransaction(txn) } func (b *Bot) fulfillTransaction(txn *BotTransaction) { switch txn.Type { case "credit_topup": bal, err := b.store.AdjustCredits(b.ctx, txn.TelegramID, txn.Credits, "topup", &txn.ID) if err != nil { log.Printf("[bot] topup credit %d: %v", txn.ID, err) b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao creditar. Contate o suporte.") return } b.notify(txn.TelegramID, fmt.Sprintf("✅ Recarga aprovada! +%d créditos.\n💳 Saldo atual: %d créditos.", txn.Credits, bal)) case "plan_renewal": b.fulfillRenewal(txn) default: b.fulfillPurchase(txn) } } func (b *Bot) fulfillPurchase(txn *BotTransaction) { if txn.PlanID == nil { return } p, err := b.store.GetPlan(b.ctx, *txn.PlanID) if err != nil { log.Printf("[bot] fulfill purchase: plan %v: %v", txn.PlanID, err) b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas o plano não foi encontrado. Contate o suporte.") return } exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour) if p.Kind == "xray" { uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, "", b.cfg.XrayPublicHost) if err != nil { log.Printf("[bot] fulfill xray: %v", err) b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta Xray. Contate o suporte.") return } _ = b.store.SetTransactionTarget(b.ctx, txn.ID, uuid) b.notify(txn.TelegramID, b.formatXrayDelivery(p.Name, uuid, link, exp)) return } user := genUsername("ssh") pass := genPassword() if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, ""); err != nil { log.Printf("[bot] fulfill ssh: %v", err) b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta SSH. Contate o suporte.") return } _ = b.store.SetTransactionTarget(b.ctx, txn.ID, user) b.notify(txn.TelegramID, b.formatSSHDelivery(p.Name, user, pass, exp)) } func (b *Bot) fulfillRenewal(txn *BotTransaction) { if txn.PlanID == nil || txn.RenewTarget == "" { return } p, err := b.store.GetPlan(b.ctx, *txn.PlanID) if err != nil { return } parts := strings.SplitN(txn.RenewTarget, ":", 2) if len(parts) < 2 { return } kind, id := parts[0], parts[1] base := time.Now() add := time.Duration(p.Days) * 24 * time.Hour if kind == "x" { newExp := base.Add(add) if meta, err := b.store.GetXrayClientMeta(b.ctx, id); err == nil && meta.ExpiresAt != nil && meta.ExpiresAt.After(base) { newExp = meta.ExpiresAt.Add(add) } if err := renewXrayClient(b.ctx, b.store, id, newExp); err != nil { log.Printf("[bot] renew xray %s: %v", id, err) b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.") return } _ = b.store.SetTransactionTarget(b.ctx, txn.ID, id) b.notify(txn.TelegramID, fmt.Sprintf("✅ %s renovado!\n⚡ Xray %s\nNova validade: %s", htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04"))) return } newExp := base.Add(add) if u, ok := userMgr.Get(id); ok && u.ExpiresAt != nil && u.ExpiresAt.After(base) { newExp = u.ExpiresAt.Add(add) } if err := renewSSHUser(b.ctx, b.store, id, newExp); err != nil { log.Printf("[bot] renew ssh %s: %v", id, err) b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.") return } _ = b.store.SetTransactionTarget(b.ctx, txn.ID, id) b.notify(txn.TelegramID, fmt.Sprintf("✅ %s renovado!\n🔒 SSH %s\nNova validade: %s", htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04"))) } // ---------- "Verificar" button ---------- func (b *Bot) checkPaymentButton(chatID int64, from *tgUser, txnIDStr string) { id, _ := strconv.Atoi(txnIDStr) txn, err := b.store.GetTransaction(b.ctx, id) if err != nil { b.send(chatID, "Pagamento não encontrado.", backKeyboard()) return } if txn.TelegramID != from.ID { b.send(chatID, "Pagamento inválido.", backKeyboard()) return } switch txn.Status { case "approved": b.send(chatID, "✅ Pagamento já confirmado! Veja em 🛍️ Minhas Compras.", backKeyboard()) return case "expired": b.send(chatID, "⌛ Este PIX expirou. Gere um novo pagamento.", backKeyboard()) return case "refunded": b.send(chatID, "Este pagamento foi estornado.", backKeyboard()) return } if b.mp == nil { b.send(chatID, "Pagamento não configurado.", backKeyboard()) return } status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID) if err != nil { b.send(chatID, "Não foi possível verificar agora. Tente novamente em instantes.", backKeyboard()) return } if status == "approved" { b.tryFulfill(txn.ID) // delivers via notify return } b.send(chatID, "⏱ Pagamento ainda não identificado. Assim que cair, a liberação é automática.", kb([]tgInlineButton{btn("🔄 Verificar novamente", "pay:check:"+botItoa(txn.ID)), btn("⬅️ Voltar", "menu:main")})) }