You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

451 lines
14 KiB
Go

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.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSubscribed,
Args: map[string]any{"durationKey": "subscription.week", "priceRub": 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.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedBuffRefillRub,
Args: map[string]any{"buffType": string(bt), "priceRub": priceRUB},
},
})
h.logger.Info("buff refill via Telegram Payment",
"hero_id", hero.ID,
"buff_type", bt,
"price_rub", priceRUB,
"telegram_charge_id", sp.TelegramPaymentChargeID,
)
}
// addLogLine writes an adventure log entry for the hero (no WS mirror from payments webhook).
func (h *PaymentsHandler) addLogLine(heroID int64, line model.AdventureLogLine) {
if h.logStore == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.logStore.Add(ctx, heroID, line); 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
}