storj/satellite/console/consoleweb/consoleapi/payments.go
Jeremy Wharton f2ae202024 satellite/payments,web/satellite: separate UI cost estimates by partner
Components related to project usage costs have been updated to show
different estimations for each partner, and the satellite has been
updated to send the client the information it needs to do this.

Previously, project costs in the satellite frontend were estimated
using only the price model corresponding to the partner that the user
registered with. This caused users who had a project containing
differently-attributed buckets to see an incorrect price estimation.

Resolves storj/storj-private#186

Change-Id: I2531643bc49f24fcb2e5f87e528b552285b6ff20
2023-04-04 15:56:16 +00:00

574 lines
15 KiB
Go

// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/private/web"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/billing"
"storj.io/storj/satellite/payments/paymentsconfig"
)
var (
// ErrPaymentsAPI - console payments api error type.
ErrPaymentsAPI = errs.Class("consoleapi payments")
mon = monkit.Package()
)
// Payments is an api controller that exposes all payment related functionality.
type Payments struct {
log *zap.Logger
service *console.Service
accountFreezeService *console.AccountFreezeService
packagePlans paymentsconfig.PackagePlans
}
// NewPayments is a constructor for api payments controller.
func NewPayments(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, packagePlans paymentsconfig.PackagePlans) *Payments {
return &Payments{
log: log,
service: service,
accountFreezeService: accountFreezeService,
packagePlans: packagePlans,
}
}
// SetupAccount creates a payment account for the user.
func (p *Payments) SetupAccount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
couponType, err := p.service.Payments().SetupAccount(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
err = json.NewEncoder(w).Encode(couponType)
if err != nil {
p.log.Error("failed to write json token deposit response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// AccountBalance returns an integer amount in cents that represents the current balance of payment account.
func (p *Payments) AccountBalance(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
balance, err := p.service.Payments().AccountBalance(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
err = json.NewEncoder(w).Encode(&balance)
if err != nil {
p.log.Error("failed to write json balance response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// ProjectsCharges returns how much money current user will be charged for each project which he owns.
func (p *Payments) ProjectsCharges(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var response struct {
PriceModels map[string]payments.ProjectUsagePriceModel `json:"priceModels"`
Charges payments.ProjectChargesResponse `json:"charges"`
}
w.Header().Set("Content-Type", "application/json")
sinceStamp, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
if err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
beforeStamp, err := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
if err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
since := time.Unix(sinceStamp, 0).UTC()
before := time.Unix(beforeStamp, 0).UTC()
charges, err := p.service.Payments().ProjectsCharges(ctx, since, before)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
response.Charges = charges
response.PriceModels = make(map[string]payments.ProjectUsagePriceModel)
seen := make(map[string]struct{})
for _, partnerCharges := range charges {
for partner := range partnerCharges {
if _, ok := seen[partner]; ok {
continue
}
response.PriceModels[partner] = *p.service.Payments().GetProjectUsagePriceModel(partner)
seen[partner] = struct{}{}
}
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
p.log.Error("failed to write json project usage and charges response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// triggerAttemptPaymentIfFrozenOrWarned checks if the account is frozen and if frozen, will trigger attempt to pay outstanding invoices.
func (p *Payments) triggerAttemptPaymentIfFrozenOrWarned(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
userID, err := p.service.GetUserID(ctx)
if err != nil {
return err
}
freeze, warning, err := p.accountFreezeService.GetAll(ctx, userID)
if err != nil {
return err
}
if freeze != nil || warning != nil {
err = p.service.Payments().AttemptPayOverdueInvoices(ctx)
if err != nil {
return err
}
}
if freeze != nil {
err = p.accountFreezeService.UnfreezeUser(ctx, userID)
if err != nil {
return err
}
} else if warning != nil {
err = p.accountFreezeService.UnWarnUser(ctx, userID)
if err != nil {
return err
}
}
return nil
}
// AddCreditCard is used to save new credit card and attach it to payment account.
func (p *Payments) AddCreditCard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
token := string(bodyBytes)
_, err = p.service.Payments().AddCreditCard(ctx, token)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
err = p.triggerAttemptPaymentIfFrozenOrWarned(ctx)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
// ListCreditCards returns a list of credit cards for a given payment account.
func (p *Payments) ListCreditCards(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
cards, err := p.service.Payments().ListCreditCards(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if cards == nil {
_, err = w.Write([]byte("[]"))
} else {
err = json.NewEncoder(w).Encode(cards)
}
if err != nil {
p.log.Error("failed to write json list cards response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// MakeCreditCardDefault makes a credit card default payment method.
func (p *Payments) MakeCreditCardDefault(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
cardID, err := io.ReadAll(r.Body)
if err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
err = p.service.Payments().MakeCreditCardDefault(ctx, string(cardID))
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
err = p.triggerAttemptPaymentIfFrozenOrWarned(ctx)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
// RemoveCreditCard is used to detach a credit card from payment account.
func (p *Payments) RemoveCreditCard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
vars := mux.Vars(r)
cardID := vars["cardId"]
if cardID == "" {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
err = p.service.Payments().RemoveCreditCard(ctx, cardID)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
// BillingHistory returns a list of invoices, transactions and all others billing history items for payment account.
func (p *Payments) BillingHistory(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
billingHistory, err := p.service.Payments().BillingHistory(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if billingHistory == nil {
_, err = w.Write([]byte("[]"))
} else {
err = json.NewEncoder(w).Encode(billingHistory)
}
if err != nil {
p.log.Error("failed to write json billing history response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// ApplyCouponCode applies a coupon code to the user's account.
func (p *Payments) ApplyCouponCode(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
// limit the size of the body to prevent excessive memory usage
bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, 1*1024*1024))
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
couponCode := string(bodyBytes)
coupon, err := p.service.Payments().ApplyCouponCode(ctx, couponCode)
if err != nil {
status := http.StatusInternalServerError
if payments.ErrInvalidCoupon.Has(err) {
status = http.StatusBadRequest
} else if payments.ErrCouponConflict.Has(err) {
status = http.StatusConflict
}
p.serveJSONError(w, status, err)
return
}
if err = json.NewEncoder(w).Encode(coupon); err != nil {
p.log.Error("failed to encode coupon", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// GetCoupon returns the coupon applied to the user's account.
func (p *Payments) GetCoupon(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
coupon, err := p.service.Payments().GetCoupon(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if err = json.NewEncoder(w).Encode(coupon); err != nil {
p.log.Error("failed to encode coupon", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// GetWallet returns the wallet address (with balance) already assigned to the user.
func (p *Payments) GetWallet(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
walletInfo, err := p.service.Payments().GetWallet(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
if errs.Is(err, billing.ErrNoWallet) {
p.serveJSONError(w, http.StatusNotFound, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if err = json.NewEncoder(w).Encode(walletInfo); err != nil {
p.log.Error("failed to encode wallet info", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// ClaimWallet will claim a new wallet address. Returns with existing if it's already claimed.
func (p *Payments) ClaimWallet(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
walletInfo, err := p.service.Payments().ClaimWallet(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if err = json.NewEncoder(w).Encode(walletInfo); err != nil {
p.log.Error("failed to encode wallet info", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// WalletPayments returns with the list of storjscan transactions for user`s wallet.
func (p *Payments) WalletPayments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
walletPayments, err := p.service.Payments().WalletPayments(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if err = json.NewEncoder(w).Encode(walletPayments); err != nil {
p.log.Error("failed to encode payments", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// GetProjectUsagePriceModel returns the project usage price model for the user.
func (p *Payments) GetProjectUsagePriceModel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
user, err := console.GetUser(ctx)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
pricing := p.service.Payments().GetProjectUsagePriceModel(string(user.UserAgent))
if err = json.NewEncoder(w).Encode(pricing); err != nil {
p.log.Error("failed to encode project usage price model", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// PurchasePackage purchases one of the configured paymentsconfig.PackagePlans.
func (p *Payments) PurchasePackage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
token := string(bodyBytes)
u, err := console.GetUser(ctx)
if err != nil {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
pkg, err := p.packagePlans.Get(u.UserAgent)
if err != nil {
p.serveJSONError(w, http.StatusNotFound, err)
return
}
card, err := p.service.Payments().AddCreditCard(ctx, token)
if err != nil {
switch {
case console.ErrUnauthorized.Has(err):
p.serveJSONError(w, http.StatusUnauthorized, err)
default:
p.serveJSONError(w, http.StatusInternalServerError, err)
}
return
}
description := fmt.Sprintf("%s package plan", string(u.UserAgent))
err = p.service.Payments().UpdatePackage(ctx, description, time.Now())
if err != nil {
if !console.ErrAlreadyHasPackage.Has(err) {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
err = p.service.Payments().Purchase(ctx, pkg.Price, description, card.ID)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if err = p.service.Payments().ApplyCredit(ctx, pkg.Credit, description); err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
// PackageAvailable returns whether a package plan is configured for the user's partner.
func (p *Payments) PackageAvailable(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
u, err := console.GetUser(ctx)
if err != nil {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
pkg, err := p.packagePlans.Get(u.UserAgent)
hasPkg := err == nil && pkg != payments.PackagePlan{}
if err = json.NewEncoder(w).Encode(hasPkg); err != nil {
p.log.Error("failed to encode package plan checking response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// serveJSONError writes JSON error to response output stream.
func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) {
web.ServeJSONError(p.log, w, status, err)
}