feat(stars): sync implement unified purchase flow
This commit is contained in:
parent
c13fbf8885
commit
7e0f9d1e62
26 changed files with 2087 additions and 282 deletions
|
|
@ -135,6 +135,25 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
|
|||
return &tg.MessageMediaEmpty{}
|
||||
}
|
||||
return tgWebPageMedia(*m.WebPage)
|
||||
case domain.MessageMediaKindGiveaway:
|
||||
if m.Giveaway == nil {
|
||||
return &tg.MessageMediaEmpty{}
|
||||
}
|
||||
out := &tg.MessageMediaGiveaway{
|
||||
OnlyNewSubscribers: m.Giveaway.OnlyNewSubscribers,
|
||||
WinnersAreVisible: m.Giveaway.WinnersAreVisible,
|
||||
Channels: append([]int64(nil), m.Giveaway.Channels...),
|
||||
Quantity: m.Giveaway.Quantity,
|
||||
UntilDate: m.Giveaway.UntilDate,
|
||||
}
|
||||
if m.Giveaway.CountriesISO2 != nil {
|
||||
out.SetCountriesISO2(append([]string(nil), m.Giveaway.CountriesISO2...))
|
||||
}
|
||||
if m.Giveaway.PrizeDescription != "" {
|
||||
out.SetPrizeDescription(m.Giveaway.PrizeDescription)
|
||||
}
|
||||
out.SetStars(m.Giveaway.Stars)
|
||||
return out
|
||||
default:
|
||||
return &tg.MessageMediaEmpty{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
|
|
@ -15,9 +16,21 @@ import (
|
|||
// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余
|
||||
// gift/auction/revenue 第一阶段兼容桩。
|
||||
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.PaymentsCanPurchaseStoreRequest](d, tlprofile.SemanticMethodPaymentsCanPurchaseStore, func(ctx context.Context, req *tg.PaymentsCanPurchaseStoreRequest) (any, error) {
|
||||
return r.onPaymentsCanPurchaseStore(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsAssignPlayMarketTransactionRequest](d, tlprofile.SemanticMethodPaymentsAssignPlayMarketTransaction, func(ctx context.Context, req *tg.PaymentsAssignPlayMarketTransactionRequest) (any, error) {
|
||||
return r.onPaymentsAssignPlayMarketTransaction(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsGiftOptions(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsGiveawayOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiveawayOptions, func(ctx context.Context, _ *tg.PaymentsGetStarsGiveawayOptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsGiveawayOptions(ctx)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetGiveawayInfoRequest](d, tlprofile.SemanticMethodPaymentsGetGiveawayInfo, func(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (any, error) {
|
||||
return r.onPaymentsGetGiveawayInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any,
|
||||
|
||||
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
||||
|
|
@ -33,6 +46,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsGetStarsStatusRequest](d, tlprofile.SemanticMethodPaymentsGetStarsStatus, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsStatusRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsStatus(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsSubscriptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsSubscriptions, func(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsSubscriptions(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
|
||||
})
|
||||
|
|
@ -66,6 +82,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
|
||||
return r.onPaymentsGetPaymentForm(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.PaymentsValidateRequestedInfoRequest](d, tlprofile.SemanticMethodPaymentsValidateRequestedInfo, func(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (any, error) {
|
||||
return r.onPaymentsValidateRequestedInfo(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) {
|
||||
return r.onPaymentsSendStarsForm(ctx, layerRequest)
|
||||
})
|
||||
|
|
@ -157,6 +176,24 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsCanPurchaseStore(ctx context.Context, _ *tg.PaymentsCanPurchaseStoreRequest) (bool, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
// telesrv deliberately exposes no Google Play products or receipt verifier.
|
||||
// DrKLO is steered to the invoice flow by appConfig; if a stale client still
|
||||
// reaches this preflight, fail closed instead of authorizing an unverifiable
|
||||
// external charge.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsAssignPlayMarketTransaction(ctx context.Context, _ *tg.PaymentsAssignPlayMarketTransactionRequest) (tg.UpdatesClass, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return nil, tgerr.New(400, "STORE_PAYMENT_UNAVAILABLE")
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
|
||||
// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
|
||||
// and bot revenue remain the bounded compatibility response because their
|
||||
|
|
@ -269,6 +306,31 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG
|
|||
return emptyStarsStatus(&tg.StarsAmount{Amount: bal.Balance}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsSubscriptions returns the authoritative current balance
|
||||
// with an empty subscription page. telesrv does not create recurring Stars
|
||||
// subscriptions yet; returning a well-shaped terminal page lets both official
|
||||
// clients finish loading the Stars screen without inventing subscription state.
|
||||
func (r *Router) onPaymentsGetStarsSubscriptions(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
if req == nil || len(req.Offset) > domain.MaxStarsTransactionsOffsetBytes {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, owner, err := r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner.Type != domain.PeerTypeUser || owner.ID != userID {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return emptyStarsStatus(&tg.StarsAmount{}), nil
|
||||
}
|
||||
balance, err := r.deps.Stars.GetBalance(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return emptyStarsStatus(&tg.StarsAmount{Amount: balance.Balance}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
|
||||
// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。
|
||||
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
|
@ -40,9 +43,114 @@ func devStarsGiftOptions() []tg.StarsGiftOption {
|
|||
}
|
||||
}
|
||||
|
||||
type starsGiftPurchaseService interface {
|
||||
IssueGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error)
|
||||
PurchaseGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error)
|
||||
func devStarsGiveawayOptions() []tg.StarsGiveawayOption {
|
||||
return []tg.StarsGiveawayOption{
|
||||
{Default: true, Stars: 1000, YearlyBoosts: 4, Currency: "USD", Amount: 99, Winners: []tg.StarsGiveawayWinnersOption{
|
||||
{Default: true, Users: 1, PerUserStars: 1000}, {Users: 2, PerUserStars: 500},
|
||||
{Users: 5, PerUserStars: 200}, {Users: 10, PerUserStars: 100},
|
||||
}},
|
||||
{Stars: 2500, YearlyBoosts: 10, Currency: "USD", Amount: 199, Winners: []tg.StarsGiveawayWinnersOption{
|
||||
{Default: true, Users: 1, PerUserStars: 2500}, {Users: 5, PerUserStars: 500}, {Users: 10, PerUserStars: 250},
|
||||
}},
|
||||
{Stars: 5000, YearlyBoosts: 20, Currency: "USD", Amount: 399, Winners: []tg.StarsGiveawayWinnersOption{
|
||||
{Default: true, Users: 1, PerUserStars: 5000}, {Users: 5, PerUserStars: 1000}, {Users: 10, PerUserStars: 500},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) devStarsFiatPaymentForm(
|
||||
buyerUserID int64,
|
||||
form domain.StarsPurchaseForm,
|
||||
title, description string,
|
||||
users []domain.User,
|
||||
) *tg.PaymentsPaymentForm {
|
||||
return &tg.PaymentsPaymentForm{
|
||||
FormID: form.FormID,
|
||||
BotID: domain.OfficialSystemUserID,
|
||||
Title: title, Description: description,
|
||||
Invoice: tg.Invoice{
|
||||
Test: true, Currency: form.Currency,
|
||||
Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: form.Amount}},
|
||||
},
|
||||
ProviderID: domain.OfficialSystemUserID,
|
||||
URL: r.publicLinkQuery("payments/dev-stars", url.Values{
|
||||
"form_id": []string{strconv.FormatInt(form.FormID, 10)},
|
||||
}),
|
||||
Users: tgUsersForViewer(buyerUserID, users),
|
||||
}
|
||||
}
|
||||
|
||||
func validDevStarsPaymentCredentials(credentials tg.InputPaymentCredentialsClass, formID int64) bool {
|
||||
value, ok := credentials.(*tg.InputPaymentCredentials)
|
||||
if !ok || value == nil || value.Save || formID == 0 {
|
||||
return false
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal([]byte(value.Data.Data), &payload); err != nil || len(payload) != 2 {
|
||||
return false
|
||||
}
|
||||
return payload["type"] == "telesrv_dev" && payload["form_id"] == strconv.FormatInt(formID, 10)
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetStarsGiveawayOptions(ctx context.Context) ([]tg.StarsGiveawayOption, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return devStarsGiveawayOptions(), nil
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsGetGiveawayInfo(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (tg.PaymentsGiveawayInfoClass, error) {
|
||||
if req == nil || req.MsgID <= 0 {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
service, ok := r.deps.Stars.(starsGiveawayInfoService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
info, err := service.GetGiveawayInfo(ctx, userID, peer.ID, req.MsgID, int(r.clock.Now().Unix()))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
out := &tg.PaymentsGiveawayInfo{StartDate: info.StartDate}
|
||||
if info.Participating {
|
||||
out.SetParticipating(true)
|
||||
}
|
||||
if info.PreparingResults {
|
||||
out.SetPreparingResults(true)
|
||||
}
|
||||
if info.JoinedTooEarlyDate > 0 {
|
||||
out.SetJoinedTooEarlyDate(info.JoinedTooEarlyDate)
|
||||
}
|
||||
if info.AdminDisallowedChatID > 0 {
|
||||
out.SetAdminDisallowedChatID(info.AdminDisallowedChatID)
|
||||
}
|
||||
if info.DisallowedCountry != "" {
|
||||
out.SetDisallowedCountry(info.DisallowedCountry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type starsPurchaseService interface {
|
||||
IssuePurchaseForm(context.Context, domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error)
|
||||
Purchase(context.Context, domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error)
|
||||
}
|
||||
|
||||
type starsGiveawayInfoService interface {
|
||||
GetGiveawayInfo(context.Context, int64, int64, int, int) (domain.StarsGiveawayInfo, error)
|
||||
}
|
||||
|
||||
func userGiftUnavailableErr() error { return tgerr.New(400, "USER_GIFT_UNAVAILABLE") }
|
||||
|
|
@ -106,6 +214,125 @@ func starsGiftPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGift
|
|||
return purpose, ok && purpose != nil
|
||||
}
|
||||
|
||||
func starsGiveawayPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGiveaway, bool) {
|
||||
if inv == nil {
|
||||
return nil, false
|
||||
}
|
||||
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsGiveaway)
|
||||
return purpose, ok && purpose != nil
|
||||
}
|
||||
|
||||
func (r *Router) validateStarsGiveawayPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.StarsGiveawayOption, *domain.StarsGiveawayPurchase, error) {
|
||||
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" ||
|
||||
purpose.RandomID == 0 || purpose.Users <= 0 || purpose.UntilDate <= 0 || purpose.BoostPeer == nil {
|
||||
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
|
||||
}
|
||||
var matched tg.StarsGiveawayOption
|
||||
var perUserStars int64
|
||||
found := false
|
||||
for _, option := range devStarsGiveawayOptions() {
|
||||
if option.Stars != purpose.Stars || option.Currency != purpose.Currency || option.Amount != purpose.Amount {
|
||||
continue
|
||||
}
|
||||
for _, winners := range option.Winners {
|
||||
if winners.Users == purpose.Users {
|
||||
matched, perUserStars, found = option, winners.PerUserStars, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return tg.StarsGiveawayOption{}, nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
if purpose.UntilDate <= now || purpose.UntilDate > now+7*24*60*60 || len(purpose.AdditionalPeers) > 10 ||
|
||||
len(purpose.CountriesISO2) > 10 || len([]rune(purpose.PrizeDescription)) > 128 {
|
||||
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
|
||||
}
|
||||
boostPeer, err := r.starsGiveawayAdminChannel(ctx, userID, purpose.BoostPeer)
|
||||
if err != nil {
|
||||
return tg.StarsGiveawayOption{}, nil, err
|
||||
}
|
||||
additional := make([]domain.Peer, 0, len(purpose.AdditionalPeers))
|
||||
seenChannels := map[int64]struct{}{boostPeer.ID: struct{}{}}
|
||||
for _, input := range purpose.AdditionalPeers {
|
||||
peer, err := r.starsGiveawayAdminChannel(ctx, userID, input)
|
||||
if err != nil {
|
||||
return tg.StarsGiveawayOption{}, nil, err
|
||||
}
|
||||
if _, exists := seenChannels[peer.ID]; exists {
|
||||
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
|
||||
}
|
||||
seenChannels[peer.ID] = struct{}{}
|
||||
additional = append(additional, peer)
|
||||
}
|
||||
countries := append([]string(nil), purpose.CountriesISO2...)
|
||||
seenCountries := make(map[string]struct{}, len(countries))
|
||||
for _, country := range countries {
|
||||
if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' {
|
||||
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
|
||||
}
|
||||
if _, exists := seenCountries[country]; exists {
|
||||
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
|
||||
}
|
||||
seenCountries[country] = struct{}{}
|
||||
}
|
||||
return matched, &domain.StarsGiveawayPurchase{
|
||||
BoostPeer: boostPeer, AdditionalPeers: additional, CountriesISO2: countries,
|
||||
PrizeDescription: purpose.PrizeDescription, RandomID: purpose.RandomID, UntilDate: purpose.UntilDate,
|
||||
Users: purpose.Users, PerUserStars: perUserStars, YearlyBoosts: matched.YearlyBoosts,
|
||||
OnlyNewSubscribers: purpose.OnlyNewSubscribers, WinnersAreVisible: purpose.WinnersAreVisible,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) starsGiveawayAdminChannel(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Peer{}, notImplementedErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
if err != nil {
|
||||
return domain.Peer{}, err
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
|
||||
return domain.Peer{}, peerIDInvalidErr()
|
||||
}
|
||||
view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.Peer{}, channelInvalidErr(err)
|
||||
}
|
||||
if view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) ||
|
||||
(view.Channel.Broadcast && !view.Self.CanPostChannelMessages()) {
|
||||
return domain.Peer{}, tgerr400("CHAT_ADMIN_REQUIRED")
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (r *Router) starsGiveawayPaymentForm(ctx context.Context, buyerUserID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.PaymentsPaymentFormClass, error) {
|
||||
_, giveaway, err := r.validateStarsGiveawayPurpose(ctx, buyerUserID, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, ok := r.deps.Stars.(starsPurchaseService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{
|
||||
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: buyerUserID, Giveaway: giveaway,
|
||||
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
|
||||
IssuedAt: now, ExpiresAt: now + 600,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
return r.devStarsFiatPaymentForm(buyerUserID, form,
|
||||
"Stars giveaway", "Launch a Stars giveaway",
|
||||
[]domain.User{domain.OfficialSystemUser()}), nil
|
||||
}
|
||||
|
||||
// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。
|
||||
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
|
||||
if r.deps.Gifts == nil {
|
||||
|
|
@ -133,10 +360,12 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
|
|||
|
||||
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice:
|
||||
// - inputInvoiceStarGift 返回 paymentFormStarGift。
|
||||
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
|
||||
// - inputInvoiceStars(top-up/gift/giveaway) 返回普通 test paymentForm,由本地 dev
|
||||
// provider WebView 生成 form-bound credentials 后走 sendPaymentForm。
|
||||
//
|
||||
// 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match),
|
||||
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTR(DrKLO/TDesktop 读 prices.front())。
|
||||
// Stars 法币购买表单 Invoice.Prices 必须非空且保持套餐 currency/amount;不能返回
|
||||
// paymentFormStars,否则 TDesktop 会把它解释为花现有 XTR,Android 会打开空 URL WebView。
|
||||
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
|
|
@ -150,6 +379,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
if purpose, gift := starsGiftPurpose(inv); gift {
|
||||
return r.starsGiftPaymentForm(ctx, userID, purpose)
|
||||
}
|
||||
if purpose, giveaway := starsGiveawayPurpose(inv); giveaway {
|
||||
return r.starsGiveawayPaymentForm(ctx, userID, purpose)
|
||||
}
|
||||
purpose, ok := starsTopupPurpose(inv)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
|
|
@ -160,7 +392,7 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.starsTopupPaymentForm(userID, purpose), nil
|
||||
return r.starsTopupPaymentForm(ctx, userID, purpose)
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
|
||||
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
|
||||
|
|
@ -228,6 +460,57 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
}, nil
|
||||
}
|
||||
|
||||
// onPaymentsValidateRequestedInfo is the read-only pre-submit gate used by
|
||||
// TDesktop's ordinary payment-form checkout. Direct Stars purchase invoices
|
||||
// advertise no requested information or flexible shipping, so a valid result
|
||||
// deliberately carries neither an ID nor shipping options. Package, recipient
|
||||
// and giveaway permissions are revalidated without issuing a form or writing
|
||||
// ledger/message/update state. Other invoice families remain unsupported here
|
||||
// rather than being accepted as an empty generic bot invoice.
|
||||
func (r *Router) onPaymentsValidateRequestedInfo(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (*tg.PaymentsValidatedRequestedInfo, error) {
|
||||
if req == nil || req.Invoice == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
// The forms returned by devStarsFiatPaymentForm request no personal data.
|
||||
// Reject even present-but-empty flags so a caller cannot make data appear
|
||||
// validated when this module neither stores nor forwards it. Save is safe as
|
||||
// a no-op only while the information object is exactly empty.
|
||||
if !req.Info.Zero() {
|
||||
return nil, tgerr.New(400, "REQUESTED_INFO_INVALID")
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
inv, ok := req.Invoice.(*tg.InputInvoiceStars)
|
||||
if !ok || inv == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if purpose, ok := starsGiftPurpose(inv); ok {
|
||||
if _, err := validateStarsGiftOption(purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := r.starsGiftRecipient(ctx, userID, purpose.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tg.PaymentsValidatedRequestedInfo{}, nil
|
||||
}
|
||||
if purpose, ok := starsGiveawayPurpose(inv); ok {
|
||||
if _, _, err := r.validateStarsGiveawayPurpose(ctx, userID, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tg.PaymentsValidatedRequestedInfo{}, nil
|
||||
}
|
||||
purpose, ok := starsTopupPurpose(inv)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tg.PaymentsValidatedRequestedInfo{}, nil
|
||||
}
|
||||
|
||||
func (r *Router) starsGiftPaymentForm(ctx context.Context, buyerUserID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentFormClass, error) {
|
||||
if _, err := validateStarsGiftOption(purpose); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -236,31 +519,27 @@ func (r *Router) starsGiftPaymentForm(ctx context.Context, buyerUserID int64, pu
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, ok := r.deps.Stars.(starsGiftPurchaseService)
|
||||
service, ok := r.deps.Stars.(starsPurchaseService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
form, err := service.IssueGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{
|
||||
BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
|
||||
form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{
|
||||
Kind: domain.StarsPurchaseGift, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
|
||||
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
|
||||
IssuedAt: now, ExpiresAt: now + 600,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starsGiftPurchaseErr(err)
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
users := []domain.User{domain.OfficialSystemUser(), recipient}
|
||||
return &tg.PaymentsPaymentFormStars{
|
||||
FormID: form.FormID, BotID: domain.OfficialSystemUserID,
|
||||
Title: "Gift Stars", Description: "Gift Stars to a friend",
|
||||
Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}}},
|
||||
Users: tgUsersForViewer(buyerUserID, users),
|
||||
}, nil
|
||||
return r.devStarsFiatPaymentForm(buyerUserID, form,
|
||||
"Gift Stars", "Gift Stars to a friend", users), nil
|
||||
}
|
||||
|
||||
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup:
|
||||
// - star gift: Debit→投递/记账,失败补偿退款。
|
||||
// - topup: 校验测试包白名单→Credit 本地账本。
|
||||
// onPaymentsSendStarsForm 只处理真正以 XTR/TON 付款的 Star Gift invoice。
|
||||
// 直接购买 Stars 是法币 test paymentForm,必须携带 dev provider credentials 走
|
||||
// sendPaymentForm;禁止把它伪装为 sendStarsForm 后无凭证铸币。
|
||||
//
|
||||
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。
|
||||
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。
|
||||
|
|
@ -273,11 +552,8 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
return nil, internalErr()
|
||||
}
|
||||
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||
if purpose, gift := starsGiftPurpose(inv); gift {
|
||||
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
|
||||
}
|
||||
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
|
||||
if _, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||
return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID")
|
||||
}
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
|
||||
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
|
||||
|
|
@ -378,10 +654,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
// onPaymentsSendPaymentForm is the Android invoice-billing completion path.
|
||||
// The local development provider does not charge an external card, but it
|
||||
// accepts only a persisted Stars-gift form and runs the same atomic settlement
|
||||
// as TDesktop's paymentFormStars/sendStarsForm compatibility path.
|
||||
// onPaymentsSendPaymentForm is the common Android/TDesktop fiat checkout path.
|
||||
// The local dev provider does not charge an external card; its WebView emits a
|
||||
// form-bound marker, and the persisted form/purpose remains the authoritative
|
||||
// package and idempotency boundary.
|
||||
func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (tg.PaymentsPaymentResultClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
|
|
@ -394,17 +670,25 @@ func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.Payments
|
|||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
purpose, ok := starsGiftPurpose(inv)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if req.Credentials == nil {
|
||||
if !validDevStarsPaymentCredentials(req.Credentials, req.FormID) {
|
||||
return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID")
|
||||
}
|
||||
if req.TipAmount != 0 {
|
||||
if _, present := req.GetRequestedInfoID(); present || req.RequestedInfoID != "" {
|
||||
return nil, tgerr.New(400, "REQUESTED_INFO_ID_INVALID")
|
||||
}
|
||||
if _, present := req.GetShippingOptionID(); present || req.ShippingOptionID != "" {
|
||||
return nil, tgerr.New(400, "SHIPPING_OPTION_INVALID")
|
||||
}
|
||||
if _, present := req.GetTipAmount(); present || req.TipAmount != 0 {
|
||||
return nil, tgerr.New(400, "TIP_AMOUNT_INVALID")
|
||||
}
|
||||
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
|
||||
if purpose, ok := starsGiftPurpose(inv); ok {
|
||||
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
|
||||
}
|
||||
if purpose, ok := starsGiveawayPurpose(inv); ok {
|
||||
return r.sendStarsGiveawayPurchase(ctx, userID, req.FormID, purpose)
|
||||
}
|
||||
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
|
||||
func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentResultClass, error) {
|
||||
|
|
@ -418,30 +702,69 @@ func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, ok := r.deps.Stars.(starsGiftPurchaseService)
|
||||
service, ok := r.deps.Stars.(starsPurchaseService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
result, err := service.PurchaseGift(ctx, domain.StarsGiftPurchaseRequest{
|
||||
StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{
|
||||
FormID: formID, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
|
||||
result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{
|
||||
StarsPurchaseForm: domain.StarsPurchaseForm{
|
||||
FormID: formID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
|
||||
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
|
||||
},
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starsGiftPurchaseErr(err)
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
updates := r.starGiftSendUpdates(ctx, buyerUserID, result.Send)
|
||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
func starsGiftPurchaseErr(err error) error {
|
||||
func (r *Router) sendStarsGiveawayPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.PaymentsPaymentResultClass, error) {
|
||||
if formID == 0 {
|
||||
return nil, formIDEmptyErr()
|
||||
}
|
||||
_, giveaway, err := r.validateStarsGiveawayPurpose(ctx, buyerUserID, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, ok := r.deps.Stars.(starsPurchaseService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{
|
||||
StarsPurchaseForm: domain.StarsPurchaseForm{
|
||||
FormID: formID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: buyerUserID, Giveaway: giveaway,
|
||||
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
|
||||
},
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) ||
|
||||
errors.Is(err, domain.ErrChannelWriteForbidden) || errors.Is(err, domain.ErrChannelAdminRequired) ||
|
||||
errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
return nil, channelInvalidErr(err)
|
||||
}
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
updates := r.channelMessageUpdatesWithPeerCache(ctx, buyerUserID, result.ChannelSend, 0, newViewerPeerCache(r))
|
||||
if updates == nil {
|
||||
updates = &tg.Updates{Date: int(r.clock.Now().Unix())}
|
||||
}
|
||||
if !result.Duplicate {
|
||||
r.enqueueChannelMessageFanout(ctx, buyerUserID, result.ChannelSend, nil)
|
||||
r.pushChannelDiscussionUpdate(ctx, buyerUserID, result.ChannelSend.Discussion)
|
||||
}
|
||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
func starsPurchaseErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrStarsGiftFormExpired):
|
||||
case errors.Is(err, domain.ErrStarsPurchaseFormExpired):
|
||||
return tgerr.New(400, "FORM_EXPIRED")
|
||||
case errors.Is(err, domain.ErrStarsGiftFormInvalid):
|
||||
case errors.Is(err, domain.ErrStarsPurchaseFormInvalid):
|
||||
return starsFormAmountMismatchErr()
|
||||
case errors.Is(err, domain.ErrStarsGiftUnavailable):
|
||||
return userGiftUnavailableErr()
|
||||
|
|
@ -512,18 +835,28 @@ func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, pu
|
|||
return matched, peer, nil
|
||||
}
|
||||
|
||||
func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePaymentStarsTopup) *tg.PaymentsPaymentFormStars {
|
||||
return &tg.PaymentsPaymentFormStars{
|
||||
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
|
||||
BotID: domain.OfficialSystemUserID,
|
||||
Title: branding.StarsName,
|
||||
Description: "telesrv dev Stars top-up",
|
||||
Invoice: tg.Invoice{
|
||||
Currency: "XTR",
|
||||
Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}},
|
||||
},
|
||||
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
|
||||
func (r *Router) starsTopupPaymentForm(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.PaymentsPaymentFormClass, error) {
|
||||
service, ok := r.deps.Stars.(starsPurchaseService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{
|
||||
Kind: domain.StarsPurchaseTopup, BuyerUserID: userID,
|
||||
SpendPurposePeer: peer,
|
||||
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
|
||||
IssuedAt: now, ExpiresAt: now + 600,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
return r.devStarsFiatPaymentForm(userID, form,
|
||||
branding.StarsName, "telesrv dev Stars top-up",
|
||||
[]domain.User{domain.OfficialSystemUser()}), nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
|
||||
|
|
@ -534,24 +867,27 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
|
|||
if formID == 0 {
|
||||
return nil, formIDEmptyErr()
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
service, ok := r.deps.Stars.(starsPurchaseService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if formID != starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount) {
|
||||
return nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
balance, err := r.deps.Stars.Credit(ctx, userID, purpose.Stars, domain.StarsReasonTopup, peer, "Stars top-up", "telesrv dev purchase")
|
||||
result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{
|
||||
StarsPurchaseForm: domain.StarsPurchaseForm{
|
||||
FormID: formID, Kind: domain.StarsPurchaseTopup, BuyerUserID: userID,
|
||||
SpendPurposePeer: peer,
|
||||
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
|
||||
},
|
||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
|
||||
OriginSessionID: sessionIDOrZero(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
return nil, starsPurchaseErr(err)
|
||||
}
|
||||
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
|
||||
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(result.Balance.Balance, r.clock.Now().Unix())}, nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
|
||||
|
|
@ -1419,20 +1755,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
|
|||
return ids
|
||||
}
|
||||
|
||||
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
|
||||
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
|
||||
for _, ch := range currency {
|
||||
id = id*131 + int64(ch)
|
||||
}
|
||||
if id < 0 {
|
||||
id = ^id
|
||||
}
|
||||
if id == 0 {
|
||||
id = 0x5354
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func starsBalanceUpdates(balance int64, unixDate int64) *tg.Updates {
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance}}},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
|
|
@ -22,6 +24,83 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type starsTopupRPCStore struct {
|
||||
*memory.StarsStore
|
||||
nextFormID int64
|
||||
forms map[int64]domain.StarsPurchaseForm
|
||||
settled map[int64]domain.StarsPurchaseResult
|
||||
channel domain.Channel
|
||||
}
|
||||
|
||||
func devStarsCredentials(formID int64) *tg.InputPaymentCredentials {
|
||||
return &tg.InputPaymentCredentials{Data: tg.DataJSON{Data: fmt.Sprintf(`{"type":"telesrv_dev","form_id":"%d"}`, formID)}}
|
||||
}
|
||||
|
||||
func newStarsTopupRPCStore() *starsTopupRPCStore {
|
||||
return &starsTopupRPCStore{
|
||||
StarsStore: memory.NewStarsStore(), nextFormID: 91000,
|
||||
forms: make(map[int64]domain.StarsPurchaseForm), settled: make(map[int64]domain.StarsPurchaseResult),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *starsTopupRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
s.nextFormID++
|
||||
form.FormID = s.nextFormID
|
||||
s.forms[form.FormID] = form
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func (s *starsTopupRPCStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
form, ok := s.forms[req.FormID]
|
||||
if !ok || form.Kind != req.Kind || form.BuyerUserID != req.BuyerUserID ||
|
||||
form.RecipientUserID != req.RecipientUserID || form.SpendPurposePeer != req.SpendPurposePeer ||
|
||||
form.Stars != req.Stars || form.Currency != req.Currency || form.Amount != req.Amount ||
|
||||
!reflect.DeepEqual(form.Giveaway, req.Giveaway) {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
if result, ok := s.settled[req.FormID]; ok {
|
||||
result.Duplicate = true
|
||||
return result, nil
|
||||
}
|
||||
if req.Kind == domain.StarsPurchaseGiveaway {
|
||||
g := req.Giveaway
|
||||
result := domain.StarsPurchaseResult{TransactionID: fmt.Sprintf("stars-giveaway-test:%d", req.FormID)}
|
||||
channel := s.channel
|
||||
if channel.ID == 0 {
|
||||
channel = domain.Channel{ID: g.BoostPeer.ID, Megagroup: true}
|
||||
}
|
||||
result.ChannelSend = domain.SendChannelMessageResult{
|
||||
Channel: channel,
|
||||
Message: domain.ChannelMessage{ChannelID: g.BoostPeer.ID, ID: 71, RandomID: g.RandomID, SenderUserID: req.BuyerUserID,
|
||||
Date: req.Date, Pts: 9, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{
|
||||
Channels: []int64{g.BoostPeer.ID}, Quantity: g.Users, Stars: req.Stars, UntilDate: g.UntilDate,
|
||||
}}},
|
||||
Event: domain.ChannelUpdateEvent{ChannelID: g.BoostPeer.ID, Type: domain.ChannelUpdateNewMessage, Pts: 9, PtsCount: 1, Date: req.Date},
|
||||
}
|
||||
result.ChannelSend.Event.Message = result.ChannelSend.Message
|
||||
s.settled[req.FormID] = result
|
||||
return result, nil
|
||||
}
|
||||
if req.Kind != domain.StarsPurchaseTopup {
|
||||
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
|
||||
}
|
||||
balance, err := s.StarsStore.Credit(ctx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup,
|
||||
req.SpendPurposePeer, req.Date, "Stars top-up", "test purchase")
|
||||
if err != nil {
|
||||
return domain.StarsPurchaseResult{}, err
|
||||
}
|
||||
result := domain.StarsPurchaseResult{Balance: balance, TransactionID: fmt.Sprintf("stars-topup-test:%d", req.FormID)}
|
||||
s.settled[req.FormID] = result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *starsTopupRPCStore) GetStarsGiveawayInfo(_ context.Context, viewerUserID, channelID int64, messageID, _ int) (domain.StarsGiveawayInfo, error) {
|
||||
if viewerUserID <= 0 || channelID != s.channel.ID || messageID != 71 {
|
||||
return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return domain.StarsGiveawayInfo{StartDate: 1_700_000_200, Participating: true}, nil
|
||||
}
|
||||
|
||||
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
|
||||
return starGiftTestRouterWithPremium(t, false)
|
||||
}
|
||||
|
|
@ -48,11 +127,12 @@ func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router,
|
|||
giftStore := memory.NewStarGiftStore()
|
||||
giftStore.SeedCatalog([]domain.StarGift{gift})
|
||||
gifts := appstargifts.NewService(giftStore, nil, 2)
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
starsStore := newStarsTopupRPCStore()
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398, PublicBaseURL: "https://links.example.test"}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Messages: appmessages.NewService(msgStore, dialogs),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)),
|
||||
Stars: appstars.NewService(starsStore, appstars.WithStartingGrant(1000), appstars.WithPurchaseStore(starsStore)),
|
||||
Gifts: gifts,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, sender, recipient, gift
|
||||
|
|
@ -85,6 +165,115 @@ func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarsGiveawayCatalogFormSettlementReplayAndInfo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := 1_700_000_100
|
||||
users := memory.NewUserStore()
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "GiveawayOwner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "GiveawayMember"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelStore := memory.NewChannelStore()
|
||||
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Giveaway Group", Megagroup: true,
|
||||
MemberUserIDs: []int64{member.ID}, Date: now - 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starsStore := newStarsTopupRPCStore()
|
||||
starsStore.channel = created.Channel
|
||||
r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{
|
||||
Users: appusers.NewService(users), Channels: appchannels.NewService(channelStore),
|
||||
Stars: appstars.NewService(starsStore, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(starsStore)),
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(int64(now), 0)})
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
options, err := r.onPaymentsGetStarsGiveawayOptions(ownerCtx)
|
||||
if err != nil || len(options) != 3 || len(options[0].Winners) < 2 || options[0].StoreProduct != "" {
|
||||
t.Fatalf("giveaway options=%+v err=%v", options, err)
|
||||
}
|
||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
purpose := &tg.InputStorePaymentStarsGiveaway{
|
||||
WinnersAreVisible: true, Stars: options[0].Stars, BoostPeer: peer,
|
||||
RandomID: 7201001, UntilDate: now + 3600, Currency: options[0].Currency,
|
||||
Amount: options[0].Amount, Users: options[0].Winners[1].Users,
|
||||
}
|
||||
invoice := &tg.InputInvoiceStars{Purpose: purpose}
|
||||
validated, err := r.onPaymentsValidateRequestedInfo(ownerCtx, &tg.PaymentsValidateRequestedInfoRequest{Invoice: invoice})
|
||||
if err != nil || validated == nil || !validated.Zero() {
|
||||
t.Fatalf("validate giveaway requested info=%+v err=%v", validated, err)
|
||||
}
|
||||
if len(starsStore.forms) != 0 || len(starsStore.settled) != 0 {
|
||||
t.Fatalf("giveaway validation mutated store: forms=%d settled=%d", len(starsStore.forms), len(starsStore.settled))
|
||||
}
|
||||
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
|
||||
if err != nil {
|
||||
t.Fatalf("get giveaway payment form: %v", err)
|
||||
}
|
||||
form, ok := formClass.(*tg.PaymentsPaymentForm)
|
||||
if !ok || form.FormID == 0 || !form.Invoice.Test || form.Invoice.Currency != purpose.Currency ||
|
||||
len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != purpose.Amount {
|
||||
t.Fatalf("giveaway payment form=%T %+v", formClass, formClass)
|
||||
}
|
||||
if _, err := r.onPaymentsSendStarsForm(ownerCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
|
||||
t.Fatalf("sendStarsForm fiat giveaway err=%v", err)
|
||||
}
|
||||
resultClass, err := r.onPaymentsSendPaymentForm(ownerCtx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send giveaway form: %v", err)
|
||||
}
|
||||
result, ok := resultClass.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
t.Fatalf("giveaway result=%T", resultClass)
|
||||
}
|
||||
updates, ok := result.Updates.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) == 0 {
|
||||
t.Fatalf("giveaway updates=%T %+v", result.Updates, result.Updates)
|
||||
}
|
||||
launchID := 0
|
||||
for _, update := range updates.Updates {
|
||||
newChannel, ok := update.(*tg.UpdateNewChannelMessage)
|
||||
if !ok || newChannel.PtsCount != 1 {
|
||||
continue
|
||||
}
|
||||
message, ok := newChannel.Message.(*tg.Message)
|
||||
if !ok {
|
||||
t.Fatalf("giveaway launch message=%T", newChannel.Message)
|
||||
}
|
||||
media, ok := message.Media.(*tg.MessageMediaGiveaway)
|
||||
if !ok || media.Stars != purpose.Stars || media.Quantity != purpose.Users || media.UntilDate != purpose.UntilDate {
|
||||
t.Fatalf("giveaway launch media=%T %+v", message.Media, message.Media)
|
||||
}
|
||||
launchID = message.ID
|
||||
}
|
||||
if launchID == 0 {
|
||||
t.Fatalf("giveaway updates missing updateNewChannelMessage: %+v", updates.Updates)
|
||||
}
|
||||
if _, err := r.onPaymentsSendPaymentForm(ownerCtx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
|
||||
}); err != nil {
|
||||
t.Fatalf("Android sendPaymentForm replay: %v", err)
|
||||
}
|
||||
infoClass, err := r.onPaymentsGetGiveawayInfo(ownerCtx, &tg.PaymentsGetGiveawayInfoRequest{Peer: peer, MsgID: launchID})
|
||||
info, ok := infoClass.(*tg.PaymentsGiveawayInfo)
|
||||
if err != nil || !ok || !info.Participating || info.StartDate == 0 {
|
||||
t.Fatalf("get giveaway info=%T %+v err=%v", infoClass, infoClass, err)
|
||||
}
|
||||
memberPurpose := *purpose
|
||||
memberPurpose.RandomID++
|
||||
if _, err := r.onPaymentsGetPaymentForm(WithUserID(ctx, member.ID), &tg.PaymentsGetPaymentFormRequest{
|
||||
Invoice: &tg.InputInvoiceStars{Purpose: &memberPurpose},
|
||||
}); !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
|
||||
t.Fatalf("member giveaway form err=%v, want CHAT_ADMIN_REQUIRED", err)
|
||||
}
|
||||
}
|
||||
|
||||
type uniqueGiftRPCService struct {
|
||||
GiftsService
|
||||
unique domain.UniqueStarGift
|
||||
|
|
@ -1456,7 +1645,7 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
|
|||
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}}
|
||||
giftStore := memory.NewStarGiftStore()
|
||||
giftStore.SeedCatalog([]domain.StarGift{gift})
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398, PublicBaseURL: "https://links.example.test"}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Messages: appmessages.NewService(msgStore, dialogs),
|
||||
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000
|
||||
|
|
@ -1550,30 +1739,63 @@ func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("getPaymentForm topup: %v", err)
|
||||
}
|
||||
form, ok := formRes.(*tg.PaymentsPaymentFormStars)
|
||||
form, ok := formRes.(*tg.PaymentsPaymentForm)
|
||||
if !ok {
|
||||
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes)
|
||||
t.Fatalf("form = %T, want *tg.PaymentsPaymentForm", formRes)
|
||||
}
|
||||
if form.FormID != starsTopupFormID(sender.ID, opt.Stars, opt.Currency, opt.Amount) {
|
||||
t.Fatalf("form id = %d, want deterministic topup id", form.FormID)
|
||||
if form.FormID == 0 {
|
||||
t.Fatal("form id = 0, want persisted random checkout id")
|
||||
}
|
||||
if form.BotID != domain.OfficialSystemUserID || len(form.Users) != 1 {
|
||||
t.Fatalf("form bot/users = %d/%d, want official system user", form.BotID, len(form.Users))
|
||||
}
|
||||
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Stars {
|
||||
t.Fatalf("form invoice = %+v, want XTR + 1 price %d", form.Invoice, opt.Stars)
|
||||
if !form.Invoice.Test || form.Invoice.Currency != opt.Currency || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Amount {
|
||||
t.Fatalf("form invoice = %+v, want test %s + 1 price %d", form.Invoice, opt.Currency, opt.Amount)
|
||||
}
|
||||
|
||||
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("sendStarsForm bad form err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
|
||||
t.Fatalf("sendStarsForm fiat topup err = %v, want PAYMENT_CREDENTIALS_INVALID", err)
|
||||
}
|
||||
if _, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID + 1, Invoice: inv, Credentials: devStarsCredentials(form.FormID + 1),
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("sendPaymentForm bad form err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
}
|
||||
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 {
|
||||
t.Fatalf("balance after bad form = %d, want 1000 unchanged", bal.Balance)
|
||||
}
|
||||
for name, request := range map[string]*tg.PaymentsSendPaymentFormRequest{
|
||||
"missing": {FormID: form.FormID, Invoice: inv},
|
||||
"wrong form marker": {FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID + 1)},
|
||||
"saved credentials": {FormID: form.FormID, Invoice: inv, Credentials: &tg.InputPaymentCredentials{
|
||||
Save: true, Data: devStarsCredentials(form.FormID).Data,
|
||||
}},
|
||||
} {
|
||||
if _, err := r.onPaymentsSendPaymentForm(senderCtx, request); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
|
||||
t.Fatalf("%s credentials err = %v, want PAYMENT_CREDENTIALS_INVALID", name, err)
|
||||
}
|
||||
}
|
||||
withInfo := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)}
|
||||
withInfo.SetRequestedInfoID("unexpected")
|
||||
if _, err := r.onPaymentsSendPaymentForm(senderCtx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_ID_INVALID") {
|
||||
t.Fatalf("requested info err = %v", err)
|
||||
}
|
||||
withShipping := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)}
|
||||
withShipping.SetShippingOptionID("unexpected")
|
||||
if _, err := r.onPaymentsSendPaymentForm(senderCtx, withShipping); !tgerr.Is(err, "SHIPPING_OPTION_INVALID") {
|
||||
t.Fatalf("shipping option err = %v", err)
|
||||
}
|
||||
withTip := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)}
|
||||
withTip.SetTipAmount(1)
|
||||
if _, err := r.onPaymentsSendPaymentForm(senderCtx, withTip); !tgerr.Is(err, "TIP_AMOUNT_INVALID") {
|
||||
t.Fatalf("tip err = %v", err)
|
||||
}
|
||||
|
||||
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
|
||||
payRes, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sendStarsForm topup: %v", err)
|
||||
t.Fatalf("sendPaymentForm topup: %v", err)
|
||||
}
|
||||
pay, ok := payRes.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
|
|
@ -1598,6 +1820,14 @@ func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
|
|||
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
|
||||
t.Fatalf("balance after topup = %d, want 3500", bal.Balance)
|
||||
}
|
||||
if _, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID),
|
||||
}); err != nil {
|
||||
t.Fatalf("sendPaymentForm exact replay: %v", err)
|
||||
}
|
||||
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
|
||||
t.Fatalf("balance after exact replay = %d, want 3500", bal.Balance)
|
||||
}
|
||||
page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, domain.StarsTransactionQuery{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list transactions: %v", err)
|
||||
|
|
|
|||
|
|
@ -17,18 +17,18 @@ import (
|
|||
|
||||
type starsFriendGiftRPCStore struct {
|
||||
*memory.StarsStore
|
||||
issued domain.StarsGiftPurchaseForm
|
||||
purchased domain.StarsGiftPurchaseRequest
|
||||
issued domain.StarsPurchaseForm
|
||||
purchased domain.StarsPurchaseRequest
|
||||
purchases int
|
||||
}
|
||||
|
||||
func (s *starsFriendGiftRPCStore) IssueStarsGiftPurchaseForm(_ context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
|
||||
func (s *starsFriendGiftRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
|
||||
form.FormID = 70001
|
||||
s.issued = form
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
|
||||
func (s *starsFriendGiftRPCStore) PurchaseStars(_ context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
|
||||
s.purchased = req
|
||||
s.purchases++
|
||||
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
|
||||
|
|
@ -41,9 +41,9 @@ func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domai
|
|||
recipient := sender
|
||||
recipient.ID, recipient.OwnerUserID, recipient.Peer, recipient.Out = 12, req.RecipientUserID,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, false
|
||||
return domain.StarsGiftPurchaseResult{
|
||||
RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321},
|
||||
TransactionID: "stars-gift-test",
|
||||
return domain.StarsPurchaseResult{
|
||||
Balance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321},
|
||||
TransactionID: "stars-gift-test",
|
||||
Send: domain.SendPrivateTextResult{
|
||||
SenderMessage: sender, RecipientMessage: recipient,
|
||||
SenderEvent: domain.UpdateEvent{UserID: req.BuyerUserID, Type: domain.UpdateEventNewMessage, Pts: 5, PtsCount: 1, Date: req.Date, Message: sender},
|
||||
|
|
@ -65,14 +65,14 @@ func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore,
|
|||
t.Fatal(err)
|
||||
}
|
||||
st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()}
|
||||
r := New(Config{DC: 2}, Deps{
|
||||
r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithGiftPurchaseStore(st)),
|
||||
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(st)),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return r, st, buyer, recipient
|
||||
}
|
||||
|
||||
func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
|
||||
func TestStarsFriendGiftOptionsFormAndFiatSettlement(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
|
|
@ -94,17 +94,24 @@ func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get gift payment form: %v", err)
|
||||
}
|
||||
form, ok := formClass.(*tg.PaymentsPaymentFormStars)
|
||||
if !ok || form.FormID != 70001 || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 2500 {
|
||||
form, ok := formClass.(*tg.PaymentsPaymentForm)
|
||||
if !ok || form.FormID != 70001 || !form.Invoice.Test || form.Invoice.Currency != "USD" ||
|
||||
len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 199 ||
|
||||
form.ProviderID != domain.OfficialSystemUserID || form.URL != "https://links.example.test/payments/dev-stars?form_id=70001" {
|
||||
t.Fatalf("gift payment form = %T %+v", formClass, formClass)
|
||||
}
|
||||
if st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 {
|
||||
if st.issued.Kind != domain.StarsPurchaseGift || st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 {
|
||||
t.Fatalf("issued form = %+v", st.issued)
|
||||
}
|
||||
|
||||
resultClass, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice})
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
|
||||
t.Fatalf("sendStarsForm fiat gift err = %v, want PAYMENT_CREDENTIALS_INVALID", err)
|
||||
}
|
||||
resultClass, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sendStarsForm gift: %v", err)
|
||||
t.Fatalf("sendPaymentForm gift: %v", err)
|
||||
}
|
||||
result, ok := resultClass.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
|
|
@ -130,14 +137,54 @@ func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
|
|||
t.Fatalf("purchase request = %+v count=%d", st.purchased, st.purchases)
|
||||
}
|
||||
|
||||
credentials := &tg.InputPaymentCredentials{Data: tg.DataJSON{Data: "{}"}}
|
||||
if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: form.FormID, Invoice: invoice, Credentials: credentials,
|
||||
}); err != nil {
|
||||
t.Fatalf("sendPaymentForm gift: %v", err)
|
||||
if st.purchases != 1 {
|
||||
t.Fatalf("settlement count = %d, want one fiat submit", st.purchases)
|
||||
}
|
||||
if st.purchases != 2 {
|
||||
t.Fatalf("settlement method count = %d, want 2 fake invocations", st.purchases)
|
||||
}
|
||||
|
||||
func TestStarsDirectPurchaseValidateRequestedInfoIsReadOnly(t *testing.T) {
|
||||
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
|
||||
topup := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||
Stars: 1000, Currency: "USD", Amount: 99,
|
||||
}}
|
||||
gift := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 2500, Currency: "USD", Amount: 199,
|
||||
}}
|
||||
for name, invoice := range map[string]tg.InputInvoiceClass{"topup": topup, "gift": gift} {
|
||||
result, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Save: true, Invoice: invoice,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s validateRequestedInfo: %v", name, err)
|
||||
}
|
||||
if result == nil || !result.Zero() {
|
||||
t.Fatalf("%s validated info = %+v, want flags=0", name, result)
|
||||
}
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
|
||||
withInfo := &tg.PaymentsValidateRequestedInfoRequest{Invoice: topup}
|
||||
withInfo.Info.SetName("unexpected")
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_INVALID") {
|
||||
t.Fatalf("non-empty info err=%v, want REQUESTED_INFO_INVALID", err)
|
||||
}
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Invoice: &tg.InputInvoiceSlug{Slug: "unsupported"},
|
||||
}); !tgerr.Is(err, "NOT_IMPLEMENTED") {
|
||||
t.Fatalf("non-Stars invoice err=%v, want NOT_IMPLEMENTED", err)
|
||||
}
|
||||
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
|
||||
Invoice: &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 100}},
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package err=%v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
t.Fatalf("invalid validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +204,9 @@ func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.
|
|||
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package form err = %v", err)
|
||||
}
|
||||
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: 70001, Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
|
||||
FormID: 70001, Invoice: bad, Credentials: devStarsCredentials(70001),
|
||||
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("tampered package settle err = %v", err)
|
||||
}
|
||||
if st.issued.FormID != 0 || st.purchases != 0 {
|
||||
|
|
@ -165,6 +214,27 @@ func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestAndroidStorePurchaseFailsClosedInFavorOfInvoiceCheckout(t *testing.T) {
|
||||
r, _, buyer, recipient := starsFriendGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), buyer.ID)
|
||||
purpose := &tg.InputStorePaymentStarsGift{
|
||||
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
|
||||
Stars: 1000, Currency: "USD", Amount: 99,
|
||||
}
|
||||
allowed, err := r.onPaymentsCanPurchaseStore(ctx, &tg.PaymentsCanPurchaseStoreRequest{Purpose: purpose})
|
||||
if err != nil {
|
||||
t.Fatalf("canPurchaseStore: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("canPurchaseStore = true, want false")
|
||||
}
|
||||
if _, err := r.onPaymentsAssignPlayMarketTransaction(ctx, &tg.PaymentsAssignPlayMarketTransactionRequest{
|
||||
Receipt: tg.DataJSON{Data: `{"orderId":"unverified"}`}, Purpose: purpose,
|
||||
}); !tgerr.Is(err, "STORE_PAYMENT_UNAVAILABLE") {
|
||||
t.Fatalf("assignPlayMarketTransaction err = %v, want STORE_PAYMENT_UNAVAILABLE", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftStarsRecipientProjectionCarriesBalanceOnlineAndDifference(t *testing.T) {
|
||||
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
|
||||
Currency: "USD", Amount: 99, Stars: 1000, TransactionID: "txn-1", BalanceAfter: 3100,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,27 @@ func TestOnPaymentsGetStarsStatusGranted(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOnPaymentsGetStarsSubscriptionsReturnsTerminalEmptyPage(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
status, err := r.onPaymentsGetStarsSubscriptions(ctx, &tg.PaymentsGetStarsSubscriptionsRequest{
|
||||
Peer: &tg.InputPeerSelf{}, Offset: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getStarsSubscriptions: %v", err)
|
||||
}
|
||||
amount, ok := status.Balance.(*tg.StarsAmount)
|
||||
if !ok || amount.Amount != 1000 {
|
||||
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
|
||||
}
|
||||
if subscriptions, ok := status.GetSubscriptions(); ok || len(subscriptions) != 0 {
|
||||
t.Fatalf("subscriptions = %+v ok=%v, want absent terminal page", subscriptions, ok)
|
||||
}
|
||||
if _, ok := status.GetSubscriptionsNextOffset(); ok {
|
||||
t.Fatal("empty subscription page unexpectedly has next offset")
|
||||
}
|
||||
}
|
||||
|
||||
// TON 余额未建模:返回 starsTonAmount 的合法响应(不崩客户端)。
|
||||
func TestOnPaymentsGetStarsStatusTon(t *testing.T) {
|
||||
r := starsRouter(t, 1000)
|
||||
|
|
|
|||
|
|
@ -1283,8 +1283,12 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
|||
{name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}},
|
||||
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
|
||||
{name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}},
|
||||
{name: "payments.canPurchaseStore", req: &tg.PaymentsCanPurchaseStoreRequest{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 99}}},
|
||||
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
|
||||
{name: "payments.getStarsGiftOptions", req: &tg.PaymentsGetStarsGiftOptionsRequest{}},
|
||||
{name: "payments.getStarsGiveawayOptions", req: &tg.PaymentsGetStarsGiveawayOptionsRequest{}},
|
||||
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarsSubscriptions", req: &tg.PaymentsGetStarsSubscriptionsRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
|
||||
{name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}},
|
||||
{name: "users.getRequirementsToContact", req: &tg.UsersGetRequirementsToContactRequest{ID: []tg.InputUserClass{&tg.InputUserSelf{}}}},
|
||||
|
|
|
|||
|
|
@ -234,6 +234,13 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
|
|||
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
|
||||
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
||||
}
|
||||
if msg.Media != nil && msg.Media.Giveaway != nil {
|
||||
for _, id := range msg.Media.Giveaway.Channels {
|
||||
if id != 0 && id != currentChannelID {
|
||||
channelIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
collectServiceActionPeerRefs(msg.Media, currentChannelID, userIDs, channelIDs)
|
||||
collectPollMediaUserRefs(msg.Media, userIDs)
|
||||
collectTodoMediaUserRefs(msg.Media, userIDs)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue