f2ae202024
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
574 lines
15 KiB
Go
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)
|
|
}
|