storj/satellite/console/consoleweb/consoleapi/payments.go
Vitalii c31fb9c1cf satellite/payments: restrict addition of duplicate credit cards
By this change we don't allow users to add credit cards that are already bind to their account.
We still allow the same CC number but with a different expiration date.

Issue:
https://github.com/storj/storj/issues/5597

Change-Id: Ifeb0cc5ae0c2f0f7596af4dead70ae7d20d30613
2023-09-11 13:24:43 +03:00

654 lines
18 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"
"storj.io/storj/satellite/payments/stripe"
)
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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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(ctx, w, http.StatusBadRequest, err)
return
}
beforeStamp, err := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
if err != nil {
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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)))
}
}
// triggerAttemptPayment attempts payment and unfreezes/unwarn user if needed.
func (p *Payments) triggerAttemptPayment(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
}
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(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
if stripe.ErrDuplicateCard.Has(err) {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
err = p.triggerAttemptPayment(ctx)
if err != nil {
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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(ctx, w, http.StatusBadRequest, err)
return
}
err = p.service.Payments().MakeCreditCardDefault(ctx, string(cardID))
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
err = p.triggerAttemptPayment(ctx)
if err != nil {
p.serveJSONError(ctx, 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(ctx, w, http.StatusBadRequest, err)
return
}
err = p.service.Payments().RemoveCreditCard(ctx, cardID)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
err = p.triggerAttemptPayment(ctx)
if err != nil {
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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)))
}
}
// InvoiceHistory returns a paged list of invoice history items for payment account.
func (p *Payments) InvoiceHistory(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
limitParam := query.Get("limit")
if limitParam == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'limit' is required"))
return
}
limit, pErr := strconv.ParseUint(limitParam, 10, 32)
if pErr != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
startParam := query.Get("starting_after")
endParam := query.Get("ending_before")
history, err := p.service.Payments().InvoiceHistory(ctx, console.BillingHistoryCursor{
Limit: int(limit),
StartingAfter: startParam,
EndingBefore: endParam,
})
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
err = json.NewEncoder(w).Encode(history)
if err != nil {
p.log.Error("failed to write json 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)
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
p.serveJSONError(ctx, 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(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
if errs.Is(err, billing.ErrNoWallet) {
p.serveJSONError(ctx, w, http.StatusNotFound, err)
return
}
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, 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)))
}
}
// WalletPaymentsWithConfirmations returns with the list of storjscan transactions (including confirmations count) for user`s wallet.
func (p *Payments) WalletPaymentsWithConfirmations(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().WalletPaymentsWithConfirmations(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
if err = json.NewEncoder(w).Encode(walletPayments); err != nil {
p.log.Error("failed to encode wallet payments with confirmations", 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(ctx, 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(ctx, w, http.StatusBadRequest, err)
return
}
token := string(bodyBytes)
u, err := console.GetUser(ctx)
if err != nil {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
pkg, err := p.packagePlans.Get(u.UserAgent)
if err != nil {
p.serveJSONError(ctx, w, http.StatusNotFound, err)
return
}
card, err := p.service.Payments().AddCreditCard(ctx, token)
if err != nil {
switch {
case console.ErrUnauthorized.Has(err):
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
default:
p.serveJSONError(ctx, 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(ctx, w, http.StatusInternalServerError, err)
return
}
}
err = p.service.Payments().Purchase(ctx, pkg.Price, description, card.ID)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
if err = p.service.Payments().ApplyCredit(ctx, pkg.Credit, description); err != nil {
p.serveJSONError(ctx, 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(ctx, 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(ctx context.Context, w http.ResponseWriter, status int, err error) {
web.ServeJSONError(ctx, p.log, w, status, err)
}