package handler import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "strings" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/telegram" ) // PaymentsHandler handles Telegram Payments invoice creation and webhook callbacks. type PaymentsHandler struct { botToken string paymentProviderToken string store *storage.HeroStore logStore *storage.LogStore logger *slog.Logger } // NewPaymentsHandler creates a new PaymentsHandler. func NewPaymentsHandler( botToken, paymentProviderToken string, store *storage.HeroStore, logStore *storage.LogStore, logger *slog.Logger, ) *PaymentsHandler { return &PaymentsHandler{ botToken: botToken, paymentProviderToken: paymentProviderToken, store: store, logStore: logStore, logger: logger, } } // --- Request / response types --- type createInvoiceRequest struct { Type string `json:"type"` // "subscription_weekly", "buff_refill", "resurrection_refill" BuffType string `json:"buffType"` // required when type == "buff_refill" } type createInvoiceResponse struct { InvoiceURL string `json:"invoiceUrl"` } // --- CreateInvoice --- // CreateInvoice generates a Telegram invoice link for the requested purchase. // POST /api/v1/payments/create-invoice func (h *PaymentsHandler) CreateInvoice(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"}) return } var req createInvoiceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } hero, err := h.store.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("create-invoice: load hero failed", "telegram_id", telegramID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) return } if hero == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) return } now := time.Now() params, err := h.buildInvoiceParams(req, hero.ID, now) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } link, err := telegram.CreateInvoiceLink(h.botToken, params) if err != nil { h.logger.Error("create-invoice: telegram API failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create invoice"}) return } h.logger.Info("invoice link created", "hero_id", hero.ID, "type", req.Type, "payload", params.Payload, ) writeJSON(w, http.StatusOK, createInvoiceResponse{InvoiceURL: link}) } // buildInvoiceParams maps the client request to Telegram invoice parameters. func (h *PaymentsHandler) buildInvoiceParams(req createInvoiceRequest, heroID int64, now time.Time) (telegram.InvoiceLinkParams, error) { ts := now.Unix() switch req.Type { case "subscription_weekly": return telegram.InvoiceLinkParams{ Title: "Weekly Subscription", Description: "7 days of x2 buffs and x2 revives", Payload: fmt.Sprintf("sub_weekly_%d_%d", heroID, ts), ProviderToken: h.paymentProviderToken, Currency: "RUB", Prices: []telegram.LabeledAmount{ {Label: "Weekly Subscription", Amount: int(model.SubscriptionWeeklyPrice() * 100)}, // rubles -> kopecks }, }, nil case "buff_refill": bt, valid := model.ValidBuffType(req.BuffType) if !valid { return telegram.InvoiceLinkParams{}, fmt.Errorf("invalid buff type: %s", req.BuffType) } if bt == model.BuffResurrection { return telegram.InvoiceLinkParams{}, fmt.Errorf("use type \"resurrection_refill\" for resurrection") } return telegram.InvoiceLinkParams{ Title: fmt.Sprintf("Buff Refill: %s", strings.Title(req.BuffType)), Description: fmt.Sprintf("Refill %s buff charges to maximum", req.BuffType), Payload: fmt.Sprintf("buff_%s_%d_%d", req.BuffType, heroID, ts), ProviderToken: h.paymentProviderToken, Currency: "RUB", Prices: []telegram.LabeledAmount{ {Label: "Buff Refill", Amount: model.BuffRefillPrice() * 100}, }, }, nil case "resurrection_refill": return telegram.InvoiceLinkParams{ Title: "Resurrection Refill", Description: "Refill Resurrection charges", Payload: fmt.Sprintf("buff_resurrection_%d_%d", heroID, ts), ProviderToken: h.paymentProviderToken, Currency: "RUB", Prices: []telegram.LabeledAmount{ {Label: "Resurrection Refill", Amount: model.ResurrectionRefillPrice() * 100}, }, }, nil default: return telegram.InvoiceLinkParams{}, fmt.Errorf("unknown purchase type: %s", req.Type) } } // --- Telegram Webhook --- // TelegramWebhook handles incoming Telegram Update objects for payment callbacks. // POST /api/v1/payments/telegram-webhook func (h *PaymentsHandler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { var update telegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { h.logger.Warn("telegram-webhook: invalid body", "error", err) // Always return 200 to Telegram so it does not retry garbage. w.WriteHeader(http.StatusOK) return } // Handle pre_checkout_query — must respond within 10 seconds. if update.PreCheckoutQuery != nil { h.handlePreCheckout(update.PreCheckoutQuery) w.WriteHeader(http.StatusOK) return } // Handle successful_payment inside a message. if update.Message != nil && update.Message.SuccessfulPayment != nil { h.handleSuccessfulPayment(r.Context(), update.Message) w.WriteHeader(http.StatusOK) return } // Unknown update type — acknowledge and ignore. w.WriteHeader(http.StatusOK) } // handlePreCheckout approves a pre-checkout query after basic payload validation. func (h *PaymentsHandler) handlePreCheckout(q *preCheckoutQuery) { // Validate payload format: must start with "sub_weekly_" or "buff_". payload := q.InvoicePayload valid := strings.HasPrefix(payload, "sub_weekly_") || strings.HasPrefix(payload, "buff_") if !valid { h.logger.Warn("pre_checkout: unknown payload format", "payload", payload) if err := telegram.AnswerPreCheckoutQuery(h.botToken, q.ID, false, "Unknown purchase type"); err != nil { h.logger.Error("pre_checkout: answer failed", "error", err) } return } h.logger.Info("pre_checkout: approving", "query_id", q.ID, "payload", payload) if err := telegram.AnswerPreCheckoutQuery(h.botToken, q.ID, true, ""); err != nil { h.logger.Error("pre_checkout: answer failed", "error", err) } } // handleSuccessfulPayment processes a completed Telegram payment. func (h *PaymentsHandler) handleSuccessfulPayment(ctx context.Context, msg *telegramMessage) { sp := msg.SuccessfulPayment payload := sp.InvoicePayload h.logger.Info("successful_payment received", "payload", payload, "total_amount", sp.TotalAmount, "currency", sp.Currency, "telegram_charge_id", sp.TelegramPaymentChargeID, "provider_charge_id", sp.ProviderPaymentChargeID, ) heroID, err := parseHeroIDFromPayload(payload) if err != nil { h.logger.Error("successful_payment: parse payload failed", "payload", payload, "error", err) return } hero, err := h.store.GetByID(ctx, heroID) if err != nil || hero == nil { h.logger.Error("successful_payment: load hero failed", "hero_id", heroID, "error", err) return } now := time.Now() switch { case strings.HasPrefix(payload, "sub_weekly_"): h.applySubscription(ctx, hero, now, sp) case strings.HasPrefix(payload, "buff_"): h.applyBuffRefill(ctx, hero, now, payload, sp) default: h.logger.Error("successful_payment: unknown payload prefix", "payload", payload) } } // applySubscription activates a weekly subscription for the hero. func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Hero, now time.Time, sp *successfulPayment) { hero.ActivateSubscription(now) // Upgrade buff charges to subscriber limits. hero.EnsureBuffChargesPopulated(now) for bt := range model.BuffFreeChargesPerType { state := hero.GetBuffCharges(bt, now) subMax := hero.MaxBuffCharges(bt) if state.Remaining < subMax { state.Remaining = subMax hero.BuffCharges[string(bt)] = state } } payment := &model.Payment{ HeroID: hero.ID, Type: "subscription_weekly", AmountRUB: int(model.SubscriptionWeeklyPrice()), Status: model.PaymentCompleted, CreatedAt: now, CompletedAt: &now, } if err := h.store.CreatePayment(ctx, payment); err != nil { h.logger.Error("successful_payment: create payment record failed", "hero_id", hero.ID, "error", err) } if err := h.store.Save(ctx, hero); err != nil { h.logger.Error("successful_payment: save hero failed", "hero_id", hero.ID, "error", err) return } h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPrice())) h.logger.Info("subscription activated via Telegram Payment", "hero_id", hero.ID, "telegram_charge_id", sp.TelegramPaymentChargeID, "expires_at", hero.SubscriptionExpiresAt, ) } // applyBuffRefill resets a specific buff's charges after a successful payment. func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero, now time.Time, payload string, sp *successfulPayment) { buffTypeStr, err := parseBuffTypeFromPayload(payload) if err != nil { h.logger.Error("successful_payment: parse buff type failed", "payload", payload, "error", err) return } bt, valid := model.ValidBuffType(buffTypeStr) if !valid { h.logger.Error("successful_payment: invalid buff type in payload", "buff_type", buffTypeStr) return } priceRUB := model.BuffRefillPrice() paymentType := model.PaymentBuffReplenish if bt == model.BuffResurrection { priceRUB = model.ResurrectionRefillPrice() paymentType = model.PaymentResurrectionReplenish } hero.ResetBuffCharges(&bt, now) payment := &model.Payment{ HeroID: hero.ID, Type: paymentType, BuffType: string(bt), AmountRUB: priceRUB, Status: model.PaymentCompleted, CreatedAt: now, CompletedAt: &now, } if err := h.store.CreatePayment(ctx, payment); err != nil { h.logger.Error("successful_payment: create payment record failed", "hero_id", hero.ID, "error", err) } if err := h.store.Save(ctx, hero); err != nil { h.logger.Error("successful_payment: save hero failed", "hero_id", hero.ID, "error", err) return } h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s (%d₽)", bt, priceRUB)) h.logger.Info("buff refill via Telegram Payment", "hero_id", hero.ID, "buff_type", bt, "price_rub", priceRUB, "telegram_charge_id", sp.TelegramPaymentChargeID, ) } // addLog writes an adventure log entry for the hero. func (h *PaymentsHandler) addLog(heroID int64, message string) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := h.logStore.Add(ctx, heroID, message); err != nil { h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err) } } // --- SetWebhook --- // SetWebhook registers the Telegram webhook URL for payment callbacks. // POST /admin/payments/set-webhook func (h *PaymentsHandler) SetWebhook(w http.ResponseWriter, r *http.Request) { var req struct { URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provide a non-empty url"}) return } if err := telegram.SetWebhook(h.botToken, req.URL); err != nil { h.logger.Error("set-webhook failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } h.logger.Info("telegram webhook set", "url", req.URL) writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "url": req.URL}) } // --- Telegram Update types (subset needed for payments) --- type telegramUpdate struct { UpdateID int64 `json:"update_id"` PreCheckoutQuery *preCheckoutQuery `json:"pre_checkout_query,omitempty"` Message *telegramMessage `json:"message,omitempty"` } type preCheckoutQuery struct { ID string `json:"id"` From tgUser `json:"from"` Currency string `json:"currency"` TotalAmount int `json:"total_amount"` InvoicePayload string `json:"invoice_payload"` } type telegramMessage struct { MessageID int `json:"message_id"` From *tgUser `json:"from,omitempty"` SuccessfulPayment *successfulPayment `json:"successful_payment,omitempty"` } type successfulPayment struct { Currency string `json:"currency"` TotalAmount int `json:"total_amount"` InvoicePayload string `json:"invoice_payload"` TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` ProviderPaymentChargeID string `json:"provider_payment_charge_id"` } type tgUser struct { ID int64 `json:"id"` IsBot bool `json:"is_bot"` FirstName string `json:"first_name"` } // --- Payload parsing helpers --- // parseHeroIDFromPayload extracts the hero ID from a payment payload string. // Payload formats: // // "sub_weekly_{heroID}_{timestamp}" // "buff_{buffType}_{heroID}_{timestamp}" func parseHeroIDFromPayload(payload string) (int64, error) { parts := strings.Split(payload, "_") switch { case strings.HasPrefix(payload, "sub_weekly_") && len(parts) >= 4: // sub_weekly_{heroID}_{ts} return strconv.ParseInt(parts[2], 10, 64) case strings.HasPrefix(payload, "buff_") && len(parts) >= 4: // buff_{type}_{heroID}_{ts} return strconv.ParseInt(parts[2], 10, 64) default: return 0, fmt.Errorf("unrecognized payload format: %s", payload) } } // parseBuffTypeFromPayload extracts the buff type string from a buff refill payload. // "buff_{type}_{heroID}_{ts}" -> "{type}" func parseBuffTypeFromPayload(payload string) (string, error) { parts := strings.Split(payload, "_") if len(parts) < 4 || parts[0] != "buff" { return "", fmt.Errorf("invalid buff payload format: %s", payload) } return parts[1], nil }