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
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
|
|
}
|