storj/satellite/console/consoleweb/consoleapi/payments.go
Bill Thorp b265b7f555 satellite/console: make paywall optional
Add a config so that some percent of users require credit cards /
account balances
in order to create a project or have a promotional coupon applied

UI was updated to match needed paywall status

At this point we decided not to use a field to store if a user is in an
A/B
test, and instead just use math to see if they're in a test.  We decided
to use MD5 (because its in Postgres too) and User UUID for that math.

Change-Id: I0fcd80707dc29afc668632d078e1b5a7a24f3bb3
2020-07-28 10:57:49 +00:00

358 lines
9.3 KiB
Go

// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi
import (
"encoding/json"
"io/ioutil"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/satellite/console"
)
var (
// ErrPaymentsAPI - console payments api error type.
ErrPaymentsAPI = errs.Class("console payments api error")
mon = monkit.Package()
)
// Payments is an api controller that exposes all payment related functionality.
type Payments struct {
log *zap.Logger
service *console.Service
}
// NewPayments is a constructor for api payments controller.
func NewPayments(log *zap.Logger, service *console.Service) *Payments {
return &Payments{
log: log,
service: service,
}
}
// 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)
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
}
}
// 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)
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
}
err = json.NewEncoder(w).Encode(charges)
if err != nil {
p.log.Error("failed to write json response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// 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 := ioutil.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
}
}
// 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
}
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 := ioutil.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
}
}
// 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
}
err = json.NewEncoder(w).Encode(billingHistory)
if err != nil {
p.log.Error("failed to write json billing history response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// TokenDeposit creates new deposit transaction and info about address and amount of newly created tx.
func (p *Payments) TokenDeposit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
var requestData struct {
Amount int64 `json:"amount"`
}
if err = json.NewDecoder(r.Body).Decode(&requestData); err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
}
if requestData.Amount < 0 {
p.serveJSONError(w, http.StatusBadRequest, errs.New("amount can not be negative"))
}
if requestData.Amount == 0 {
p.serveJSONError(w, http.StatusBadRequest, errs.New("amount should be greater than zero"))
}
tx, err := p.service.Payments().TokenDeposit(ctx, requestData.Amount)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
var responseData struct {
Address string `json:"address"`
Amount float64 `json:"amount"`
TokenAmount string `json:"tokenAmount"`
Rate string `json:"rate"`
Status string `json:"status"`
Link string `json:"link"`
ExpiresAt time.Time `json:"expires"`
}
responseData.Address = tx.Address
responseData.Amount = float64(requestData.Amount) / 100
responseData.TokenAmount = tx.Amount.String()
responseData.Rate = tx.Rate.Text('f', 8)
responseData.Status = tx.Status.String()
responseData.Link = tx.Link
responseData.ExpiresAt = tx.CreatedAt.Add(tx.Timeout)
err = json.NewEncoder(w).Encode(responseData)
if err != nil {
p.log.Error("failed to write json token deposit response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// PaywallEnabled returns is paywall enabled status.
func (p *Payments) PaywallEnabled(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
vars := mux.Vars(r)
reqID := vars["userId"]
if reqID == "" {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
userID, err := uuid.FromString(reqID)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
paywallEnabled := p.service.PaywallEnabled(userID)
err = json.NewEncoder(w).Encode(paywallEnabled)
if err != nil {
p.log.Error("failed to write json paywall enabled 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) {
if status == http.StatusInternalServerError {
p.log.Error("returning error to client", zap.Int("code", status), zap.Error(err))
} else {
p.log.Debug("returning error to client", zap.Int("code", status), zap.Error(err))
}
w.WriteHeader(status)
var response struct {
Error string `json:"error"`
}
response.Error = err.Error()
err = json.NewEncoder(w).Encode(response)
if err != nil {
p.log.Error("failed to write json error response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}