satellite/{payments,satellitedb}: Remove custom coupon implementation

Removes database tables and functionality related to our custom
coupon implementation because it has been superseded by the Stripe
coupon and promo code system. Requires implementations of the
payments Invoices interface to return coupon usages along with
invoices.

Change-Id: Iac52d2ff64afca8cc4dbb2d1f20e6ad4b39ddfde
This commit is contained in:
Jeremy Wharton 2021-08-26 19:51:26 -05:00 committed by Maximillian von Briesen
parent 1ef06fae99
commit 3b751a35c5
34 changed files with 112 additions and 2498 deletions

View File

@ -65,11 +65,7 @@ func setupPayments(log *zap.Logger, db satellite.DB) (*stripecoinpayments.Servic
pc.StorageTBPrice,
pc.EgressTBPrice,
pc.ObjectPrice,
pc.BonusRate,
pc.CouponValue,
pc.CouponDuration.IntPointer(),
pc.CouponProjectLimit,
pc.MinCoinPayment)
pc.BonusRate)
}
// parseBillingPeriodFromString parses provided date string and returns corresponding time.Time.

View File

@ -205,13 +205,6 @@ var (
Args: cobra.ExactArgs(1),
RunE: cmdCreateCustomerInvoiceItems,
}
createCustomerInvoiceCouponsCmd = &cobra.Command{
Use: "create-invoice-coupons [period]",
Short: "Adds coupons to stripe invoices",
Long: "Creates stripe invoice line items for not consumed coupons.",
Args: cobra.ExactArgs(1),
RunE: cmdCreateCustomerInvoiceCoupons,
}
createCustomerInvoicesCmd = &cobra.Command{
Use: "create-invoices [period]",
Short: "Creates stripe invoices from pending invoice items",
@ -322,7 +315,6 @@ func init() {
billingCmd.AddCommand(applyFreeTierCouponsCmd)
billingCmd.AddCommand(prepareCustomerInvoiceRecordsCmd)
billingCmd.AddCommand(createCustomerInvoiceItemsCmd)
billingCmd.AddCommand(createCustomerInvoiceCouponsCmd)
billingCmd.AddCommand(createCustomerInvoicesCmd)
billingCmd.AddCommand(finalizeCustomerInvoicesCmd)
billingCmd.AddCommand(stripeCustomerCmd)
@ -346,7 +338,6 @@ func init() {
process.Bind(applyFreeTierCouponsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(prepareCustomerInvoiceRecordsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(createCustomerInvoiceItemsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(createCustomerInvoiceCouponsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(createCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(finalizeCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(stripeCustomerCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
@ -711,19 +702,6 @@ func cmdCreateCustomerInvoiceItems(cmd *cobra.Command, args []string) (err error
})
}
func cmdCreateCustomerInvoiceCoupons(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd)
period, err := parseBillingPeriod(args[0])
if err != nil {
return errs.New("invalid period specified: %v", err)
}
return runBillingCmd(ctx, func(ctx context.Context, payments *stripecoinpayments.Service, _ satellite.DB) error {
return payments.InvoiceApplyCoupons(ctx, period)
})
}
func cmdCreateCustomerInvoices(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd)

View File

@ -440,7 +440,6 @@ func (planet *Planet) newSatellite(ctx context.Context, prefix string, index int
config.Tally.ReadRollupBatchSize = 0
config.Rollup.DeleteTallies = false
config.Payments.BonusRate = 0
config.Payments.MinCoinPayment = 0
config.Payments.NodeEgressBandwidthPrice = 0
config.Payments.NodeRepairBandwidthPrice = 0
config.Payments.NodeAuditBandwidthPrice = 0

View File

@ -133,11 +133,7 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB,
pc.StorageTBPrice,
pc.EgressTBPrice,
pc.ObjectPrice,
pc.BonusRate,
pc.CouponValue,
pc.CouponDuration.IntPointer(),
pc.CouponProjectLimit,
pc.MinCoinPayment)
pc.BonusRate)
if err != nil {
return nil, errs.Combine(err, peer.Close())

View File

@ -15,10 +15,6 @@ Requires setting `Authorization` header for requests.
* [PUT /api/users/{user-email}](#put-apiusersuser-email)
* [GET /api/users/{user-email}](#get-apiusersuser-email)
* [DELETE /api/users/{user-email}](#delete-apiusersuser-email)
* [Coupon Management](#coupon-management)
* [POST /api/coupons](#post-apicoupons)
* [GET /api/coupons/{coupon-id}](#get-apicouponscoupon-id)
* [DELETE /api/coupons/{coupon-id}](#delete-apicouponscoupon-id)
* [Project Management](#project-management)
* [POST /api/projects](#post-apiprojects)
* [GET /api/projects/{project-id}](#get-apiprojectsproject-id)
@ -132,18 +128,6 @@ A successful response body:
"description": "Project to store data.",
"ownerId": "12345678-1234-1234-1234-123456789abc"
}
],
"coupons": [
{
"id": "2fcdbb8f-8d4d-4e6d-b6a7-8aaa1eba4c89",
"userId": "12345678-1234-1234-1234-123456789abc",
"duration": 2,
"amount": 3000,
"description": "promotional coupon (valid for 2 billing cycles)",
"type": 0,
"status": 0,
"created": "2020-05-19T00:34:13.265761+02:00"
}
]
}
```
@ -152,58 +136,6 @@ A successful response body:
Deletes the user.
### Coupon Management
The coupons have an amount and duration.
Amount is expressed in cents of USD dollars (e.g. 500 is $5)
Duration is expressed in billing periods, a billing period is a natural month.
#### POST /api/coupons
Adds a coupon for specific user.
An example of a required request body:
```json
{
"userId": "12345678-1234-1234-1234-123456789abc",
"duration": 2,
"amount": 3000,
"description": "promotional coupon (valid for 2 billing cycles)"
}
```
A successful response body:
```json
{
"id": "2fcdbb8f-8d4d-4e6d-b6a7-8aaa1eba4c89"
}
```
#### GET /api/coupons/{coupon-id}
Gets a coupon with the specified id.
A successful response body:
```json
{
"id": "2fcdbb8f-8d4d-4e6d-b6a7-8aaa1eba4c89",
"userId": "12345678-1234-1234-1234-123456789abc",
"duration": 2,
"amount": 3000,
"description": "promotional coupon (valid for 2 billing cycles)",
"type": 0,
"status": 0,
"created": "2020-05-19T00:34:13.265761+02:00"
}
```
#### DELETE /api/coupons/{coupon-id}
Deletes the specified coupon.
### Project Management
#### POST /api/projects

View File

@ -1,147 +0,0 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
package admin
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"github.com/gorilla/mux"
"storj.io/common/uuid"
"storj.io/storj/satellite/payments"
)
func (server *Server) addCoupon(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
sendJSONError(w, "failed to read body",
err.Error(), http.StatusInternalServerError)
return
}
var input struct {
UserID uuid.UUID `json:"userId"`
Duration int `json:"duration"`
Amount int64 `json:"amount"`
Description string `json:"description"`
}
err = json.Unmarshal(body, &input)
if err != nil {
sendJSONError(w, "failed to unmarshal request",
err.Error(), http.StatusBadRequest)
return
}
switch {
case input.Duration == 0:
sendJSONError(w, "Duration is not set",
"", http.StatusBadRequest)
return
case input.Amount == 0:
sendJSONError(w, "Amount is not set",
"", http.StatusBadRequest)
return
case input.Description == "":
sendJSONError(w, "Description is not set",
"", http.StatusBadRequest)
return
case input.UserID.IsZero():
sendJSONError(w, "UserID is not set",
"", http.StatusBadRequest)
return
}
coupon, err := server.db.StripeCoinPayments().Coupons().Insert(ctx, payments.CouponOld{
UserID: input.UserID,
Amount: input.Amount,
Duration: &input.Duration,
Description: input.Description,
})
if err != nil {
sendJSONError(w, "failed to insert coupon",
err.Error(), http.StatusInternalServerError)
return
}
data, err := json.Marshal(coupon.ID)
if err != nil {
sendJSONError(w, "json encoding failed",
err.Error(), http.StatusInternalServerError)
return
}
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) couponInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
id, ok := vars["couponid"]
if !ok {
sendJSONError(w, "couponId missing",
"", http.StatusBadRequest)
return
}
couponID, err := uuid.FromString(id)
if err != nil {
sendJSONError(w, "invalid couponId",
"", http.StatusBadRequest)
}
coupon, err := server.db.StripeCoinPayments().Coupons().Get(ctx, couponID)
if errors.Is(err, sql.ErrNoRows) {
sendJSONError(w, fmt.Sprintf("coupon with id %q not found", couponID),
"", http.StatusNotFound)
return
}
if err != nil {
sendJSONError(w, "failed to get coupon",
err.Error(), http.StatusInternalServerError)
return
}
data, err := json.Marshal(coupon)
if err != nil {
sendJSONError(w, "json encoding failed",
err.Error(), http.StatusInternalServerError)
return
}
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) deleteCoupon(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
UUIDString, ok := vars["couponid"]
if !ok {
sendJSONError(w, "couponid missing",
"", http.StatusBadRequest)
return
}
couponID, err := uuid.FromString(UUIDString)
if err != nil {
sendJSONError(w, "invalid couponid",
err.Error(), http.StatusBadRequest)
return
}
err = server.db.StripeCoinPayments().Coupons().Delete(ctx, couponID)
if err != nil {
sendJSONError(w, "unable to delete coupon",
err.Error(), http.StatusInternalServerError)
return
}
}

View File

@ -1,178 +0,0 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
package admin_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/testcontext"
"storj.io/common/uuid"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/payments"
)
func TestAddCoupon(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
StorageNodeCount: 0,
UplinkCount: 1,
Reconfigure: testplanet.Reconfigure{
Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) {
config.Admin.Address = "127.0.0.1:0"
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
address := planet.Satellites[0].Admin.Admin.Listener.Addr()
user, err := planet.Satellites[0].DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].Projects[0].Owner.Email)
require.NoError(t, err)
body := strings.NewReader(fmt.Sprintf(`{"userId": "%s", "duration": 2, "amount": 3000, "description": "testcoupon-alice"}`, user.ID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+address.String()+"/api/coupons", body)
require.NoError(t, err)
req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken)
response, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
responseBody, err := ioutil.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
var output uuid.UUID
err = json.Unmarshal(responseBody, &output)
require.NoError(t, err)
coupon, err := planet.Satellites[0].DB.StripeCoinPayments().Coupons().Get(ctx, output)
require.NoError(t, err)
require.Equal(t, user.ID, coupon.UserID)
require.NotNil(t, coupon.Duration)
require.Equal(t, 2, *coupon.Duration)
require.Equal(t, "testcoupon-alice", coupon.Description)
require.Equal(t, int64(3000), coupon.Amount)
})
}
func TestCouponInfo(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
StorageNodeCount: 0,
UplinkCount: 1,
Reconfigure: testplanet.Reconfigure{
Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) {
config.Admin.Address = "127.0.0.1:0"
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
address := planet.Satellites[0].Admin.Admin.Listener.Addr()
user, err := planet.Satellites[0].DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].Projects[0].Owner.Email)
require.NoError(t, err)
var output payments.CouponOld
var id uuid.UUID
body := strings.NewReader(fmt.Sprintf(`{"userId": "%s", "duration": 2, "amount": 3000, "description": "testcoupon-alice"}`, user.ID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+address.String()+"/api/coupons", body)
require.NoError(t, err)
req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken)
response, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
responseBody, err := ioutil.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
err = json.Unmarshal(responseBody, &id)
require.NoError(t, err)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://"+address.String()+"/api/coupons/%s", id.String()), nil)
require.NoError(t, err)
req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken)
response, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
responseBody, err = ioutil.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
err = json.Unmarshal(responseBody, &output)
require.NoError(t, err)
require.Equal(t, id, output.ID)
require.NotNil(t, output.Duration)
require.Equal(t, 2, *output.Duration)
require.Equal(t, int64(3000), output.Amount)
require.Equal(t, "testcoupon-alice", output.Description)
})
}
func TestCouponDelete(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
StorageNodeCount: 0,
UplinkCount: 1,
Reconfigure: testplanet.Reconfigure{
Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) {
config.Admin.Address = "127.0.0.1:0"
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
address := planet.Satellites[0].Admin.Admin.Listener.Addr()
user, err := planet.Satellites[0].DB.Console().Users().GetByEmail(ctx, planet.Uplinks[0].Projects[0].Owner.Email)
require.NoError(t, err)
var id uuid.UUID
body := strings.NewReader(fmt.Sprintf(`{"userId": "%s", "duration": 2, "amount": 3000, "description": "testcoupon-alice"}`, user.ID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+address.String()+"/api/coupons", body)
require.NoError(t, err)
req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken)
response, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
responseBody, err := ioutil.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
err = json.Unmarshal(responseBody, &id)
require.NoError(t, err)
coupons, err := planet.Satellites[0].DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err)
// each created user have always one coupon already
require.Len(t, coupons, 2)
req, err = http.NewRequestWithContext(ctx, http.MethodDelete, fmt.Sprintf("http://"+address.String()+"/api/coupons/%s", id), nil)
require.NoError(t, err)
req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken)
response, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "", response.Header.Get("Content-Type"))
coupons, err = planet.Satellites[0].DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err)
require.Len(t, coupons, 1)
})
}

View File

@ -81,9 +81,6 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, accounts payments.
server.mux.HandleFunc("/api/users/{useremail}", server.updateUser).Methods("PUT")
server.mux.HandleFunc("/api/users/{useremail}", server.userInfo).Methods("GET")
server.mux.HandleFunc("/api/users/{useremail}", server.deleteUser).Methods("DELETE")
server.mux.HandleFunc("/api/coupons", server.addCoupon).Methods("POST")
server.mux.HandleFunc("/api/coupons/{couponid}", server.couponInfo).Methods("GET")
server.mux.HandleFunc("/api/coupons/{couponid}", server.deleteCoupon).Methods("DELETE")
server.mux.HandleFunc("/api/projects", server.addProject).Methods("POST")
server.mux.HandleFunc("/api/projects/{project}/usage", server.checkProjectUsage).Methods("GET")
server.mux.HandleFunc("/api/projects/{project}/limit", server.getProjectLimit).Methods("GET")

View File

@ -16,7 +16,6 @@ import (
"storj.io/common/uuid"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
)
func (server *Server) addUser(w http.ResponseWriter, r *http.Request) {
@ -148,13 +147,6 @@ func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) {
return
}
coupons, err := server.db.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
if err != nil {
sendJSONError(w, "failed to get user coupons",
err.Error(), http.StatusInternalServerError)
return
}
type User struct {
ID uuid.UUID `json:"id"`
FullName string `json:"fullName"`
@ -169,9 +161,8 @@ func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) {
}
var output struct {
User User `json:"user"`
Projects []Project `json:"projects"`
Coupons []payments.CouponOld `json:"coupons"`
User User `json:"user"`
Projects []Project `json:"projects"`
}
output.User = User{
@ -188,7 +179,6 @@ func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) {
OwnerID: p.OwnerID,
})
}
output.Coupons = coupons
data, err := json.Marshal(output)
if err != nil {
@ -320,7 +310,7 @@ func (server *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
}
// ensure no unpaid invoices exist.
invoices, err := server.payments.Invoices().List(ctx, user.ID)
invoices, _, err := server.payments.Invoices().List(ctx, user.ID)
if err != nil {
sendJSONError(w, "unable to list user invoices",
err.Error(), http.StatusInternalServerError)

View File

@ -38,17 +38,10 @@ func TestUserGet(t *testing.T) {
projLimit, err := sat.DB.Console().Users().GetProjectLimit(ctx, project.Owner.ID)
require.NoError(t, err)
coupons, err := sat.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, project.Owner.ID)
require.NoError(t, err)
couponsMarshaled, err := json.Marshal(coupons)
require.NoError(t, err)
link := "http://" + address.String() + "/api/users/" + project.Owner.Email
expectedBody := `{` +
fmt.Sprintf(`"user":{"id":"%s","fullName":"User uplink0_0","email":"%s","projectLimit":%d},`, project.Owner.ID, project.Owner.Email, projLimit) +
fmt.Sprintf(`"projects":[{"id":"%s","name":"uplink0_0","description":"","ownerId":"%s"}],`, project.ID, project.Owner.ID) +
fmt.Sprintf(`"coupons":%s}`, couponsMarshaled)
fmt.Sprintf(`"projects":[{"id":"%s","name":"uplink0_0","description":"","ownerId":"%s"}]}`, project.ID, project.Owner.ID)
assertReq(ctx, t, link, http.MethodGet, "", http.StatusOK, expectedBody, planet.Satellites[0].Config.Console.AuthToken)

View File

@ -531,11 +531,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
pc.StorageTBPrice,
pc.EgressTBPrice,
pc.ObjectPrice,
pc.BonusRate,
pc.CouponValue,
pc.CouponDuration.IntPointer(),
pc.CouponProjectLimit,
pc.MinCoinPayment)
pc.BonusRate)
if err != nil {
return nil, errs.Combine(err, peer.Close())
@ -576,7 +572,6 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Payments.Accounts,
peer.Analytics.Service,
consoleConfig.Config,
config.Payments.MinCoinPayment,
)
if err != nil {
return nil, errs.Combine(err, peer.Close())

View File

@ -166,7 +166,12 @@ func (p *Payments) ListCreditCards(w http.ResponseWriter, r *http.Request) {
return
}
err = json.NewEncoder(w).Encode(cards)
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)))
}
@ -241,7 +246,12 @@ func (p *Payments) BillingHistory(w http.ResponseWriter, r *http.Request) {
return
}
err = json.NewEncoder(w).Encode(billingHistory)
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)))
}

View File

@ -89,11 +89,7 @@ func TestGraphqlMutation(t *testing.T) {
pc.StorageTBPrice,
pc.EgressTBPrice,
pc.ObjectPrice,
pc.BonusRate,
pc.CouponValue,
pc.CouponDuration.IntPointer(),
pc.CouponProjectLimit,
pc.MinCoinPayment)
pc.BonusRate)
require.NoError(t, err)
service, err := console.NewService(
@ -107,7 +103,6 @@ func TestGraphqlMutation(t *testing.T) {
paymentsService.Accounts(),
analyticsService,
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5},
5000,
)
require.NoError(t, err)

View File

@ -73,11 +73,7 @@ func TestGraphqlQuery(t *testing.T) {
pc.StorageTBPrice,
pc.EgressTBPrice,
pc.ObjectPrice,
pc.BonusRate,
pc.CouponValue,
pc.CouponDuration.IntPointer(),
pc.CouponProjectLimit,
pc.MinCoinPayment)
pc.BonusRate)
require.NoError(t, err)
service, err := console.NewService(
@ -91,7 +87,6 @@ func TestGraphqlQuery(t *testing.T) {
paymentsService.Accounts(),
analyticsService,
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5},
5000,
)
require.NoError(t, err)

View File

@ -137,8 +137,7 @@ func TestPayments(t *testing.T) {
{ // Get_PaymentCards_EmptyReturn
resp, body := test.request(http.MethodGet, "/payments/cards", nil)
// TODO: this should be []
require.JSONEq(t, "null", body)
require.JSONEq(t, "[]", body)
require.Equal(t, http.StatusOK, resp.StatusCode)
}
@ -150,7 +149,7 @@ func TestPayments(t *testing.T) {
{ // Get_BillingHistory
resp, body := test.request(http.MethodGet, "/payments/billing-history", nil)
require.Contains(t, body, "description")
require.JSONEq(t, "[]", body)
require.Equal(t, http.StatusOK, resp.StatusCode)
}

View File

@ -112,8 +112,6 @@ type Service struct {
analytics *analytics.Service
config Config
minCoinPayment int64
}
func init() {
@ -150,7 +148,7 @@ type PaymentsService struct {
}
// NewService returns new instance of Service.
func NewService(log *zap.Logger, signer Signer, store DB, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets Buckets, partners *rewards.PartnersService, accounts payments.Accounts, analytics *analytics.Service, config Config, minCoinPayment int64) (*Service, error) {
func NewService(log *zap.Logger, signer Signer, store DB, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets Buckets, partners *rewards.PartnersService, accounts payments.Accounts, analytics *analytics.Service, config Config) (*Service, error) {
if signer == nil {
return nil, errs.New("signer can't be nil")
}
@ -177,7 +175,6 @@ func NewService(log *zap.Logger, signer Signer, store DB, projectAccounting acco
recaptchaHandler: NewDefaultRecaptcha(config.Recaptcha.SecretKey),
analytics: analytics,
config: config,
minCoinPayment: minCoinPayment,
}, nil
}
@ -354,7 +351,7 @@ func (paymentService PaymentsService) BillingHistory(ctx context.Context) (billi
return nil, Error.Wrap(err)
}
invoices, err := paymentService.service.accounts.Invoices().List(ctx, auth.User.ID)
invoices, couponUsages, err := paymentService.service.accounts.Invoices().List(ctx, auth.User.ID)
if err != nil {
return nil, Error.Wrap(err)
}
@ -408,47 +405,22 @@ func (paymentService PaymentsService) BillingHistory(ctx context.Context) (billi
})
}
coupons, err := paymentService.service.accounts.Coupons().ListByUserID(ctx, auth.User.ID)
if err != nil {
return nil, Error.Wrap(err)
}
for _, coupon := range coupons {
alreadyUsed, err := paymentService.service.accounts.Coupons().TotalUsage(ctx, coupon.ID)
if err != nil {
return nil, Error.Wrap(err)
for _, usage := range couponUsages {
desc := "Coupon"
if usage.Coupon.Name != "" {
desc = usage.Coupon.Name
}
if usage.Coupon.PromoCode != "" {
desc += " (" + usage.Coupon.PromoCode + ")"
}
remaining := coupon.Amount - alreadyUsed
if coupon.Status == payments.CouponExpired {
remaining = 0
}
var couponStatus string
switch coupon.Status {
case 0:
couponStatus = "Active"
case 1:
couponStatus = "Used"
default:
couponStatus = "Expired"
}
billingHistoryItem := &BillingHistoryItem{
ID: coupon.ID.String(),
Description: coupon.Description,
Amount: coupon.Amount,
Remaining: remaining,
Status: couponStatus,
Link: "",
Start: coupon.Created,
billingHistory = append(billingHistory, &BillingHistoryItem{
Description: desc,
Amount: usage.Amount,
Start: usage.PeriodStart,
End: usage.PeriodEnd,
Type: Coupon,
}
if coupon.ExpirationDate() != nil {
billingHistoryItem.End = *coupon.ExpirationDate()
}
billingHistory = append(billingHistory, billingHistoryItem)
})
}
bonuses, err := paymentService.service.accounts.StorjTokens().ListDepositBonuses(ctx, auth.User.ID)
@ -500,7 +472,7 @@ func (paymentService PaymentsService) checkOutstandingInvoice(ctx context.Contex
return err
}
invoices, err := paymentService.service.accounts.Invoices().List(ctx, auth.User.ID)
invoices, _, err := paymentService.service.accounts.Invoices().List(ctx, auth.User.ID)
if err != nil {
return err
}
@ -1149,12 +1121,6 @@ func (s *Service) CreateProject(ctx context.Context, projectInfo ProjectInfo) (p
s.analytics.TrackProjectCreated(auth.User.ID, projectID, currentProjectCount+1)
// ToDo: check if this is actually the right place.
err = s.accounts.Coupons().AddPromotionalCoupon(ctx, auth.User.ID)
if err != nil {
s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err)))
}
return p, nil
}

View File

@ -456,11 +456,7 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
pc.StorageTBPrice,
pc.EgressTBPrice,
pc.ObjectPrice,
pc.BonusRate,
pc.CouponValue,
pc.CouponDuration.IntPointer(),
pc.CouponProjectLimit,
pc.MinCoinPayment)
pc.BonusRate)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}

View File

@ -7,7 +7,6 @@ import (
"context"
"time"
"storj.io/common/memory"
"storj.io/common/uuid"
)
@ -18,25 +17,6 @@ type Coupons interface {
// GetByUserID returns the coupon applied to the specified user.
GetByUserID(ctx context.Context, userID uuid.UUID) (*Coupon, error)
// ListByUserID return list of all coupons of specified payment account.
ListByUserID(ctx context.Context, userID uuid.UUID) ([]CouponOld, error)
// TotalUsage returns sum of all usage records for specified coupon.
TotalUsage(ctx context.Context, couponID uuid.UUID) (int64, error)
// Create attaches a coupon for payment account.
Create(ctx context.Context, coupon CouponOld) (coup CouponOld, err error)
// AddPromotionalCoupon is used to add a promotional coupon for specified users who already have
// a project and do not have a promotional coupon yet.
// And updates project limits to selected size.
AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) error
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have
// a project, payment method and do not have a promotional coupon yet.
// And updates project limits to selected size.
PopulatePromotionalCoupons(ctx context.Context, duration *int, amount int64, projectLimit memory.Size) error
// ApplyCouponCode attempts to apply a coupon code to the user.
ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) (*Coupon, error)
}
@ -64,61 +44,3 @@ const (
// CouponForever indicates that a coupon is applied every billing period forever.
CouponForever = "forever"
)
// CouponOld is an entity that adds some funds to Accounts balance for some fixed period.
// CouponOld is attached to the project.
// At the end of the period, the entire remaining coupon amount will be returned from the account balance.
// Deprecated: Use Coupon instead.
// TODO: This struct should be removed with the rest of the custom coupon implementation code.
type CouponOld struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"userId"`
Amount int64 `json:"amount"` // Amount is stored in cents.
Duration *int `json:"duration"` // Duration is stored in number of billing periods.
Description string `json:"description"`
Type CouponType `json:"type"`
Status CouponStatus `json:"status"`
Created time.Time `json:"created"`
}
// ExpirationDate returns coupon expiration date.
//
// A coupon is valid for Duration number of full months. The month the user
// signs up is not counted in the duration. The expirated date is at the last
// day of the last valid month.
func (coupon *CouponOld) ExpirationDate() *time.Time {
if coupon.Duration == nil {
return nil
}
expireDate := time.Date(coupon.Created.Year(), coupon.Created.Month()+time.Month(*coupon.Duration)+1, 1, 0, 0, 0, 0, time.UTC)
return &expireDate
}
// CouponType indicates the type of the coupon.
type CouponType int
const (
// CouponTypePromotional defines that this coupon is a promotional coupon.
CouponTypePromotional CouponType = 0
)
// CouponStatus indicates the state of the coupon.
type CouponStatus int
const (
// CouponActive is a default coupon state.
CouponActive CouponStatus = 0
// CouponUsed status indicates that coupon was used.
CouponUsed CouponStatus = 1
// CouponExpired status indicates that coupon is expired and unavailable.
CouponExpired CouponStatus = 2
)
// CouponsPage holds set of coupon and indicates if
// there are more coupons to fetch.
type CouponsPage struct {
Coupons []CouponOld
Next bool
NextOffset int64
}

View File

@ -1,44 +0,0 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
package payments
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestCoupon_ExpirationDate(t *testing.T) {
for i, tt := range []struct {
created time.Time
duration int
expires time.Time
}{
{
created: time.Date(2020, 1, 30, 0, 0, 0, 0, time.UTC), // 2020-01-30 00:00:00 +0000 UTC
duration: 0, // sign-up month only
expires: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC), // 2020-02-01 00:00:00 +0000 UTC
},
{
created: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC), // 2020-02-01 00:00:00 +0000 UTC
duration: 1, // sign-up month + 1 full month
expires: time.Date(2020, 4, 1, 0, 0, 0, 0, time.UTC), // 2020-04-01 00:00:00 +0000 UTC
},
{
created: time.Date(2020, 2, 5, 8, 0, 0, 0, time.UTC), // 2020-02-05 08:00:00 +0000 UTC
duration: 2, // sign-up month + 2 full months
expires: time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC), // 2020-05-01 00:00:00 +0000 UTC
},
} {
coupon := CouponOld{
Duration: &tt.duration,
Created: tt.created,
}
expirationDate := coupon.ExpirationDate()
require.NotNil(t, expirationDate)
require.Equal(t, tt.expires, *expirationDate, fmt.Sprint(i), expirationDate.String())
}
}

View File

@ -14,8 +14,8 @@ import (
//
// architecture: Service
type Invoices interface {
// List returns a list of invoices for a given payment account.
List(ctx context.Context, userID uuid.UUID) ([]Invoice, error)
// List returns a list of invoices and coupon usages for a given payment account.
List(ctx context.Context, userID uuid.UUID) ([]Invoice, []CouponUsage, error)
// CheckPendingItems returns if pending invoice items for a given payment account exist.
CheckPendingItems(ctx context.Context, userID uuid.UUID) (existingItems bool, err error)
}
@ -30,3 +30,11 @@ type Invoice struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// CouponUsage describes the usage of a coupon on an invoice.
type CouponUsage struct {
Coupon Coupon
Amount int64
PeriodStart time.Time
PeriodEnd time.Time
}

View File

@ -4,9 +4,6 @@
package paymentsconfig
import (
"strconv"
"storj.io/common/memory"
"storj.io/storj/satellite/payments/stripecoinpayments"
)
@ -14,27 +11,14 @@ import (
type Config struct {
Provider string `help:"payments provider to use" default:""`
StripeCoinPayments stripecoinpayments.Config
StorageTBPrice string `help:"price user should pay for storing TB per month" default:"4" testDefault:"10"`
EgressTBPrice string `help:"price user should pay for each TB of egress" default:"7" testDefault:"45"`
ObjectPrice string `help:"price user should pay for each object stored in network per month" default:"0" testDefault:"0.0000022"`
BonusRate int64 `help:"amount of percents that user will earn as bonus credits by depositing in STORJ tokens" default:"10"`
CouponValue int64 `help:"coupon value in cents" default:"165" testDefault:"275"`
CouponDuration CouponDuration `help:"duration a new coupon is valid in months/billing cycles. An empty string means the coupon never expires" default:"1" testDefault:"2"`
CouponProjectLimit memory.Size `help:"project limit to which increase to after applying the coupon, 0 B means not changing it from the default" default:"0 B"`
MinCoinPayment int64 `help:"minimum value of coin payments in cents before coupon is applied" default:"1000"`
NodeEgressBandwidthPrice int64 `help:"price node receive for storing TB of egress in cents" default:"2000"`
NodeRepairBandwidthPrice int64 `help:"price node receive for storing TB of repair in cents" default:"1000"`
NodeAuditBandwidthPrice int64 `help:"price node receive for storing TB of audit in cents" default:"1000"`
NodeDiskSpacePrice int64 `help:"price node receive for storing disk space in cents/TB" default:"150"`
}
// CouponDuration is a configuration struct that keeps details about default
// promotional coupon duration.
//
// Can be used as a flag.
type CouponDuration struct {
Enabled bool
BillingPeriods int64
StorageTBPrice string `help:"price user should pay for storing TB per month" default:"4" testDefault:"10"`
EgressTBPrice string `help:"price user should pay for each TB of egress" default:"7" testDefault:"45"`
ObjectPrice string `help:"price user should pay for each object stored in network per month" default:"0" testDefault:"0.0000022"`
BonusRate int64 `help:"amount of percents that user will earn as bonus credits by depositing in STORJ tokens" default:"10"`
NodeEgressBandwidthPrice int64 `help:"price node receive for storing TB of egress in cents" default:"2000"`
NodeRepairBandwidthPrice int64 `help:"price node receive for storing TB of repair in cents" default:"1000"`
NodeAuditBandwidthPrice int64 `help:"price node receive for storing TB of audit in cents" default:"1000"`
NodeDiskSpacePrice int64 `help:"price node receive for storing disk space in cents/TB" default:"150"`
}
// PricingValues holds pricing model for satellite.
@ -43,41 +27,3 @@ type PricingValues struct {
EgressTBPrice string
ObjectPrice string
}
// Type implements pflag.Value.
func (CouponDuration) Type() string { return "paymentsconfig.CouponDuration" }
// String is required for pflag.Value.
func (cd *CouponDuration) String() string {
if !cd.Enabled {
return ""
}
return strconv.FormatInt(cd.BillingPeriods, 10)
}
// Set sets the value from a string.
func (cd *CouponDuration) Set(s string) error {
if s == "" {
cd.Enabled = false
return nil
}
cd.Enabled = true
billingPeriods, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return err
}
cd.BillingPeriods = billingPeriods
return nil
}
// IntPointer returns an int64 pointer representation of the config value.
func (cd *CouponDuration) IntPointer() *int64 {
if !cd.Enabled {
return nil
}
return &cd.BillingPeriods
}

View File

@ -75,25 +75,8 @@ func (accounts *accounts) Balance(ctx context.Context, userID uuid.UUID) (_ paym
return payments.Balance{}, Error.Wrap(err)
}
// add all active coupons amount to balance.
coupons, err := accounts.service.db.Coupons().ListByUserIDAndStatus(ctx, userID, payments.CouponActive)
if err != nil {
return payments.Balance{}, Error.Wrap(err)
}
var couponsAmount int64
for _, coupon := range coupons {
alreadyUsed, err := accounts.service.db.Coupons().TotalUsage(ctx, coupon.ID)
if err != nil {
return payments.Balance{}, Error.Wrap(err)
}
couponsAmount += coupon.Amount - alreadyUsed
}
accountBalance := payments.Balance{
FreeCredits: couponsAmount,
Coins: -c.Balance,
Coins: -c.Balance,
}
return accountBalance, nil

View File

@ -9,75 +9,10 @@ import (
"github.com/stripe/stripe-go/v72"
"storj.io/common/memory"
"storj.io/common/uuid"
"storj.io/storj/satellite/payments"
)
// CouponsDB is an interface for managing coupons table.
//
// architecture: Database
type CouponsDB interface {
// Insert inserts a coupon into the database.
Insert(ctx context.Context, coupon payments.CouponOld) (payments.CouponOld, error)
// Update updates coupon in database.
Update(ctx context.Context, couponID uuid.UUID, status payments.CouponStatus) (payments.CouponOld, error)
// Get returns coupon by ID.
Get(ctx context.Context, couponID uuid.UUID) (payments.CouponOld, error)
// Delete removes a coupon from the database
Delete(ctx context.Context, couponID uuid.UUID) error
// List returns all coupons with specified status.
List(ctx context.Context, status payments.CouponStatus) ([]payments.CouponOld, error)
// ListByUserID returns all coupons of specified user.
ListByUserID(ctx context.Context, userID uuid.UUID) ([]payments.CouponOld, error)
// ListByUserIDAndStatus returns all coupons of specified user and status. Results are ordered (asc) by expiration date.
ListByUserIDAndStatus(ctx context.Context, userID uuid.UUID, status payments.CouponStatus) ([]payments.CouponOld, error)
// ListPending returns paginated list of coupons with specified status.
ListPaged(ctx context.Context, offset int64, limit int, before time.Time, status payments.CouponStatus) (payments.CouponsPage, error)
// AddUsage creates new coupon usage record in database.
AddUsage(ctx context.Context, usage CouponUsage) error
// TotalUsage gets sum of all usage records for specified coupon.
TotalUsage(ctx context.Context, couponID uuid.UUID) (int64, error)
// GetLatest return period_end of latest coupon charge.
GetLatest(ctx context.Context, couponID uuid.UUID) (time.Time, error)
// ListUnapplied returns coupon usage page with unapplied coupon usages.
ListUnapplied(ctx context.Context, offset int64, limit int, period time.Time) (CouponUsagePage, error)
// ApplyUsage applies coupon usage and updates its status.
ApplyUsage(ctx context.Context, couponID uuid.UUID, period time.Time) error
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have a project
// and do not have a promotional coupon yet. And updates project limits to selected size.
PopulatePromotionalCoupons(ctx context.Context, users []uuid.UUID, duration *int, amount int64, projectLimit memory.Size) error
}
// CouponUsage stores amount of money that should be charged from coupon for billing period.
type CouponUsage struct {
CouponID uuid.UUID
Amount int64
Status CouponUsageStatus
Period time.Time
}
// CouponUsageStatus indicates the state of the coupon usage.
type CouponUsageStatus int
const (
// CouponUsageStatusUnapplied is a default coupon usage state.
CouponUsageStatusUnapplied CouponUsageStatus = 0
// CouponUsageStatusApplied status indicates that coupon usage was used.
CouponUsageStatusApplied CouponUsageStatus = 1
)
// CouponUsagePage holds coupons usages and
// indicates if there is more data available
// and provides next offset.
type CouponUsagePage struct {
Usages []CouponUsage
Next bool
NextOffset int64
}
// ensures that coupons implements payments.Coupons.
var _ payments.Coupons = (*coupons)(nil)
@ -88,129 +23,6 @@ type coupons struct {
service *Service
}
// Create attaches a coupon for payment account.
func (coupons *coupons) Create(ctx context.Context, coupon payments.CouponOld) (coup payments.CouponOld, err error) {
defer mon.Task()(&ctx, coupon)(&err)
coup, err = coupons.service.db.Coupons().Insert(ctx, coupon)
return coup, Error.Wrap(err)
}
// ListByUserID return list of all coupons of specified payment account.
func (coupons *coupons) ListByUserID(ctx context.Context, userID uuid.UUID) (_ []payments.CouponOld, err error) {
defer mon.Task()(&ctx, userID)(&err)
couponList, err := coupons.service.db.Coupons().ListByUserID(ctx, userID)
return couponList, Error.Wrap(err)
}
// TotalUsage returns sum of all usage records for specified coupon.
func (coupons *coupons) TotalUsage(ctx context.Context, couponID uuid.UUID) (_ int64, err error) {
defer mon.Task()(&ctx, couponID)(&err)
totalUsage, err := coupons.service.db.Coupons().TotalUsage(ctx, couponID)
return totalUsage, Error.Wrap(err)
}
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have
// a project, payment method and do not have a promotional coupon yet.
// And updates project limits to selected size.
func (coupons *coupons) PopulatePromotionalCoupons(ctx context.Context, duration *int, amount int64, projectLimit memory.Size) (err error) {
defer mon.Task()(&ctx, duration, amount, projectLimit)(&err)
const limit = 50
before := time.Now()
cusPage, err := coupons.service.db.Customers().List(ctx, 0, limit, before)
if err != nil {
return Error.Wrap(err)
}
// taking only users that attached a payment method.
var usersIDs []uuid.UUID
for _, cus := range cusPage.Customers {
params := &stripe.PaymentMethodListParams{
Type: stripe.String(string(stripe.PaymentMethodTypeCard)),
Customer: stripe.String(cus.ID),
}
paymentMethodsIterator := coupons.service.stripeClient.PaymentMethods().List(params)
for paymentMethodsIterator.Next() {
// if user has at least 1 payment method - break a loop.
usersIDs = append(usersIDs, cus.UserID)
break
}
if err = paymentMethodsIterator.Err(); err != nil {
return Error.Wrap(err)
}
}
err = coupons.service.db.Coupons().PopulatePromotionalCoupons(ctx, usersIDs, duration, amount, projectLimit)
if err != nil {
return Error.Wrap(err)
}
for cusPage.Next {
if err = ctx.Err(); err != nil {
return Error.Wrap(err)
}
cusPage, err = coupons.service.db.Customers().List(ctx, cusPage.NextOffset, limit, before)
if err != nil {
return Error.Wrap(err)
}
// we have to wait before each iteration because
// Stripe has rate limits - 100 read and 100 write operations per second per secret key.
time.Sleep(time.Second)
var usersIDs []uuid.UUID
for _, cus := range cusPage.Customers {
params := &stripe.PaymentMethodListParams{
Type: stripe.String(string(stripe.PaymentMethodTypeCard)),
Customer: stripe.String(cus.ID),
}
paymentMethodsIterator := coupons.service.stripeClient.PaymentMethods().List(params)
for paymentMethodsIterator.Next() {
usersIDs = append(usersIDs, cus.UserID)
break
}
if err = paymentMethodsIterator.Err(); err != nil {
return Error.Wrap(err)
}
}
err = coupons.service.db.Coupons().PopulatePromotionalCoupons(ctx, usersIDs, duration, amount, projectLimit)
if err != nil {
return Error.Wrap(err)
}
}
return nil
}
// AddPromotionalCoupon is used to add a promotional coupon for specified users who already have
// a project and do not have a promotional coupon yet.
// And updates project limits to selected size.
func (coupons *coupons) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx, userID)(&err)
// convert *int64 to *int
var couponDuration *int
if coupons.service.CouponDuration != nil {
value := int(*coupons.service.CouponDuration)
couponDuration = &value
}
return Error.Wrap(coupons.service.db.Coupons().PopulatePromotionalCoupons(ctx, []uuid.UUID{userID}, couponDuration, coupons.service.CouponValue, coupons.service.CouponProjectLimit))
}
// ApplyCouponCode attempts to apply a coupon code to the user via Stripe.
func (coupons *coupons) ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) (_ *payments.Coupon, err error) {
defer mon.Task()(&ctx, userID, couponCode)(&err)

View File

@ -1,452 +0,0 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package stripecoinpayments_test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"storj.io/common/memory"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/common/uuid"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripecoinpayments"
"storj.io/storj/satellite/satellitedb/satellitedbtest"
)
func TestCouponRepository(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
duration := 2
couponsRepo := db.StripeCoinPayments().Coupons()
coupon := payments.CouponOld{
Duration: &duration,
Amount: 10,
Status: payments.CouponActive,
Description: "description",
UserID: testrand.UUID(),
}
now := time.Now().UTC()
t.Run("insert", func(t *testing.T) {
_, err := couponsRepo.Insert(ctx, coupon)
require.NoError(t, err)
coupons, err := couponsRepo.List(ctx, payments.CouponActive)
require.NoError(t, err)
require.Equal(t, 1, len(coupons))
coupon = coupons[0]
})
t.Run("update", func(t *testing.T) {
_, err := couponsRepo.Update(ctx, coupon.ID, payments.CouponUsed)
require.NoError(t, err)
coupons, err := couponsRepo.List(ctx, payments.CouponUsed)
require.NoError(t, err)
require.Equal(t, payments.CouponUsed, coupons[0].Status)
coupon = coupons[0]
})
t.Run("get latest on empty table return stripecoinpayments.ErrNoCouponUsages", func(t *testing.T) {
_, err := couponsRepo.GetLatest(ctx, coupon.ID)
require.Error(t, err)
require.Equal(t, true, stripecoinpayments.ErrNoCouponUsages.Has(err))
})
t.Run("total on empty table returns 0", func(t *testing.T) {
total, err := couponsRepo.TotalUsage(ctx, coupon.ID)
require.NoError(t, err)
require.Equal(t, int64(0), total)
})
t.Run("add usage", func(t *testing.T) {
err := couponsRepo.AddUsage(ctx, stripecoinpayments.CouponUsage{
CouponID: coupon.ID,
Amount: 1,
Period: now,
})
require.NoError(t, err)
date, err := couponsRepo.GetLatest(ctx, coupon.ID)
require.NoError(t, err)
// go and postgres has different precision. go - nanoseconds, postgres micro
require.Equal(t, date.UTC(), now.Truncate(time.Microsecond))
})
t.Run("total usage", func(t *testing.T) {
amount, err := couponsRepo.TotalUsage(ctx, coupon.ID)
require.NoError(t, err)
require.Equal(t, amount, int64(1))
})
})
}
// TestPopulatePromotionalCoupons is a test for PopulatePromotionalCoupons function
// that creates coupons with predefined values for each of user (from arguments) that have a project
// and that don't have a valid promotional coupon yet. Also it updates limits of selected projects to 1TB.
// Because the coupon should be added to a project, we select the first project of the user.
// In this test we have the following test cases:
// 1. Activated user, 2 projects, without coupon. For this case we should add new coupon to his first project.
// 2. Activated user, 1 project, without coupon. Coupon should be added.
// 3. Activated user without project. Coupon should not be added.
// 4. User with inactive account. Coupon should not be added.
// 5. Activated user with project and coupon. Coupon should not be added.
// 6. Activated user with project and expired coupon. Coupon should be added.
// 7. Activated user with project and fully consumed, non-expired coupon. Coupon should be added.
// 8. Next step - is populating coupons for all 7 users. 4 coupons should be added.
// 9. Creating new user with project.
// 10. Populating coupons again. For 8 users above. Only 1 new coupon should be added.
// Five new coupons total should be added by 2 runs of PopulatePromotionalCoupons method.
func TestPopulatePromotionalCoupons(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
usersRepo := db.Console().Users()
projectsRepo := db.Console().Projects()
couponsRepo := db.StripeCoinPayments().Coupons()
usageRepo := db.ProjectAccounting()
// creating test users with different status.
// activated user with 2 project. New coupon should be added to the first project.
user1, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user1",
ShortName: "",
Email: "test1@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user1.Status = console.Active
err = usersRepo.Update(ctx, user1)
require.NoError(t, err)
// activated user with proj. New coupon should be added.
user2, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user2",
ShortName: "",
Email: "test2@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user2.Status = console.Active
err = usersRepo.Update(ctx, user2)
require.NoError(t, err)
// activated user without proj. New coupon should not be added.
user3, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user3",
ShortName: "",
Email: "test3@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user3.Status = console.Active
err = usersRepo.Update(ctx, user3)
require.NoError(t, err)
// inactive user. New coupon should not be added.
user4, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user4",
ShortName: "",
Email: "test4@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
// activated user with proj and coupon. New coupon should not be added.
user5, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user5",
ShortName: "",
Email: "test5@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user5.Status = console.Active
err = usersRepo.Update(ctx, user5)
require.NoError(t, err)
// activated user with proj and expired coupon. New coupon should be added.
user6, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user6",
ShortName: "",
Email: "test6@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user6.Status = console.Active
err = usersRepo.Update(ctx, user6)
require.NoError(t, err)
// activated user with proj and non-expired but consumed coupon. New coupon should be added.
user7, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user7",
ShortName: "",
Email: "test7@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user7.Status = console.Active
err = usersRepo.Update(ctx, user7)
require.NoError(t, err)
// creating projects for users above.
proj1, err := projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 1",
Description: "descr 1",
OwnerID: user1.ID,
})
require.NoError(t, err)
// should not be processed as we takes only first project of the user.
proj2, err := projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 2 of user 1",
Description: "descr 2",
OwnerID: user1.ID,
})
require.NoError(t, err)
proj3, err := projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 2",
Description: "descr 3",
OwnerID: user2.ID,
})
require.NoError(t, err)
_, err = projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 5",
Description: "descr 4",
OwnerID: user5.ID,
})
require.NoError(t, err)
_, err = projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 6",
Description: "descr 5",
OwnerID: user6.ID,
})
require.NoError(t, err)
_, err = projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 7",
Description: "descr 5",
OwnerID: user7.ID,
})
require.NoError(t, err)
// add coupons to users who should have them before PopulatePromotionalCoupons
duration := 2
couponID := testrand.UUID()
_, err = couponsRepo.Insert(ctx, payments.CouponOld{
ID: couponID,
UserID: user5.ID,
Amount: 5500,
Duration: &duration,
Description: "qw",
Type: payments.CouponTypePromotional,
Status: payments.CouponActive,
})
require.NoError(t, err)
couponID = testrand.UUID()
_, err = couponsRepo.Insert(ctx, payments.CouponOld{
ID: couponID,
UserID: user6.ID,
Amount: 1000,
Duration: &duration,
Description: "qw",
Type: payments.CouponTypePromotional,
Status: payments.CouponExpired,
})
require.NoError(t, err)
couponID = testrand.UUID()
_, err = couponsRepo.Insert(ctx, payments.CouponOld{
ID: couponID,
UserID: user7.ID,
Amount: 1000,
Duration: nil,
Description: "qw",
Type: payments.CouponTypePromotional,
Status: payments.CouponUsed,
})
require.NoError(t, err)
// creating new users and projects to test that multiple execution of populate method wont generate extra coupons.
user8, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user8",
ShortName: "",
Email: "test8@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user8.Status = console.Active
err = usersRepo.Update(ctx, user8)
require.NoError(t, err)
proj4, err := projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 8",
Description: "descr 7",
OwnerID: user8.ID,
})
if err != nil {
require.NoError(t, err)
}
t.Run("first population", func(t *testing.T) {
var usersIds = []uuid.UUID{
user1.ID,
user2.ID,
user3.ID,
user4.ID,
user5.ID,
user6.ID,
user7.ID,
}
duration := 2
err := couponsRepo.PopulatePromotionalCoupons(ctx, usersIds, &duration, 5500, memory.TB)
require.NoError(t, err)
user1Coupons, err := couponsRepo.ListByUserID(ctx, user1.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user1Coupons))
proj1Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj1.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj1Usage)
proj2Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj2.ID)
require.NoError(t, err)
require.Nil(t, proj2Usage)
user2Coupons, err := couponsRepo.ListByUserID(ctx, user2.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user2Coupons))
proj3Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj3.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj3Usage)
user3Coupons, err := couponsRepo.ListByUserID(ctx, user3.ID)
require.NoError(t, err)
require.Equal(t, 0, len(user3Coupons))
user4Coupons, err := couponsRepo.ListByUserID(ctx, user4.ID)
require.NoError(t, err)
require.Equal(t, 0, len(user4Coupons))
user5Coupons, err := couponsRepo.ListByUserID(ctx, user5.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user5Coupons))
user6Coupons, err := couponsRepo.ListByUserID(ctx, user6.ID)
require.NoError(t, err)
require.Equal(t, 2, len(user6Coupons))
user7Coupons, err := couponsRepo.ListByUserID(ctx, user7.ID)
require.NoError(t, err)
require.Equal(t, 2, len(user7Coupons))
user8Coupons, err := couponsRepo.ListByUserID(ctx, user8.ID)
require.NoError(t, err)
require.Equal(t, 0, len(user8Coupons))
})
t.Run("second population", func(t *testing.T) {
var usersIds = []uuid.UUID{
user1.ID,
user2.ID,
user3.ID,
user4.ID,
user5.ID,
user6.ID,
user7.ID,
user8.ID,
}
duration := 2
err := couponsRepo.PopulatePromotionalCoupons(ctx, usersIds, &duration, 5500, memory.TB)
require.NoError(t, err)
user1Coupons, err := couponsRepo.ListByUserID(ctx, user1.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user1Coupons))
proj1Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj1.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj1Usage)
proj2Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj2.ID)
require.NoError(t, err)
require.Nil(t, proj2Usage)
user2Coupons, err := couponsRepo.ListByUserID(ctx, user2.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user2Coupons))
proj3Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj3.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj3Usage)
user3Coupons, err := couponsRepo.ListByUserID(ctx, user3.ID)
require.NoError(t, err)
require.Equal(t, 0, len(user3Coupons))
user4Coupons, err := couponsRepo.ListByUserID(ctx, user4.ID)
require.NoError(t, err)
require.Equal(t, 0, len(user4Coupons))
user5Coupons, err := couponsRepo.ListByUserID(ctx, user5.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user5Coupons))
require.Equal(t, "qw", user5Coupons[0].Description)
user6Coupons, err := couponsRepo.ListByUserID(ctx, user6.ID)
require.NoError(t, err)
require.Equal(t, 2, len(user6Coupons))
user7Coupons, err := couponsRepo.ListByUserID(ctx, user7.ID)
require.NoError(t, err)
require.Equal(t, 2, len(user7Coupons))
user8Coupons, err := couponsRepo.ListByUserID(ctx, user8.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user8Coupons))
proj4Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj4.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj4Usage)
})
})
}

View File

@ -13,6 +13,4 @@ type DB interface {
Transactions() TransactionsDB
// ProjectRecords is getter for invoice project records db.
ProjectRecords() ProjectRecordsDB
// Coupons is getter for coupons db.
Coupons() CouponsDB
}

View File

@ -20,18 +20,19 @@ type invoices struct {
service *Service
}
// List returns a list of invoices for a given payment account.
func (invoices *invoices) List(ctx context.Context, userID uuid.UUID) (invoicesList []payments.Invoice, err error) {
// List returns a list of invoices and coupon usages for a given payment account.
func (invoices *invoices) List(ctx context.Context, userID uuid.UUID) (invoicesList []payments.Invoice, couponUsages []payments.CouponUsage, err error) {
defer mon.Task()(&ctx, userID)(&err)
customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID)
if err != nil {
return nil, Error.Wrap(err)
return nil, nil, Error.Wrap(err)
}
params := &stripe.InvoiceListParams{
Customer: &customerID,
}
params.AddExpand("total_discount_amounts.discount")
invoicesIterator := invoices.service.stripeClient.Invoices().List(params)
for invoicesIterator.Next() {
@ -54,13 +55,39 @@ func (invoices *invoices) List(ctx context.Context, userID uuid.UUID) (invoicesL
Link: stripeInvoice.InvoicePDF,
Start: time.Unix(stripeInvoice.PeriodStart, 0),
})
for _, dcAmt := range stripeInvoice.TotalDiscountAmounts {
if dcAmt == nil {
return nil, nil, Error.New("discount amount is nil")
}
dc := dcAmt.Discount
coupon, err := stripeDiscountToPaymentsCoupon(dc)
if err != nil {
return nil, nil, Error.Wrap(err)
}
usage := payments.CouponUsage{
Coupon: *coupon,
Amount: dcAmt.Amount,
PeriodStart: time.Unix(stripeInvoice.PeriodStart, 0),
PeriodEnd: time.Unix(stripeInvoice.PeriodEnd, 0),
}
if dc.PromotionCode != nil {
usage.Coupon.PromoCode = dc.PromotionCode.Code
}
couponUsages = append(couponUsages, usage)
}
}
if err = invoicesIterator.Err(); err != nil {
return nil, Error.Wrap(err)
return nil, nil, Error.Wrap(err)
}
return invoicesList, nil
return invoicesList, couponUsages, nil
}
// CheckPendingItems returns if pending invoice items for a given payment account exist.

View File

@ -17,8 +17,8 @@ var ErrProjectRecordExists = Error.New("invoice project record already exists")
//
// architecture: Database
type ProjectRecordsDB interface {
// Create creates new invoice project record with coupon usages and credits spendings in the DB.
Create(ctx context.Context, records []CreateProjectRecord, couponUsages []CouponUsage, start, end time.Time) error
// Create creates new invoice project record with credits spendings in the DB.
Create(ctx context.Context, records []CreateProjectRecord, start, end time.Time) error
// Check checks if invoice project record for specified project and billing period exists.
Check(ctx context.Context, projectID uuid.UUID, start, end time.Time) error
// Get returns record for specified project and billing period.

View File

@ -39,7 +39,6 @@ func TestProjectRecords(t *testing.T) {
Objects: 3,
},
},
[]stripecoinpayments.CouponUsage{},
start, end,
)
require.NoError(t, err)
@ -93,7 +92,7 @@ func TestProjectRecordsList(t *testing.T) {
)
}
err := projectRecordsDB.Create(ctx, createProjectRecords, []stripecoinpayments.CouponUsage{}, start, end)
err := projectRecordsDB.Create(ctx, createProjectRecords, start, end)
require.NoError(t, err)
page, err := projectRecordsDB.ListUnapplied(ctx, 0, limit, start, end)

View File

@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
@ -19,7 +18,6 @@ import (
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/memory"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
@ -30,8 +28,6 @@ import (
var (
// Error defines stripecoinpayments service error.
Error = errs.Class("stripecoinpayments service")
// ErrNoCouponUsages indicates that there are no coupon usages.
ErrNoCouponUsages = errs.Class("stripecoinpayments no coupon usages")
mon = monkit.Package()
)
@ -71,11 +67,6 @@ type Service struct {
BonusRate int64
// Coupon Values
StripeFreeTierCouponID string
CouponValue int64
CouponDuration *int64
CouponProjectLimit memory.Size
// Minimum CoinPayment to create a coupon
MinCoinPayment int64
// Stripe Extended Features
AutoAdvance bool
@ -89,7 +80,7 @@ type Service struct {
}
// NewService creates a Service instance.
func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, projectsDB console.Projects, usageDB accounting.ProjectAccounting, storageTBPrice, egressTBPrice, objectPrice string, bonusRate, couponValue int64, couponDuration *int64, couponProjectLimit memory.Size, minCoinPayment int64) (*Service, error) {
func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, projectsDB console.Projects, usageDB accounting.ProjectAccounting, storageTBPrice, egressTBPrice, objectPrice string, bonusRate int64) (*Service, error) {
coinPaymentsClient := coinpayments.NewClient(
coinpayments.Credentials{
@ -128,10 +119,6 @@ func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB
ObjectMonthPriceCents: objectMonthPriceCents,
BonusRate: bonusRate,
StripeFreeTierCouponID: config.StripeFreeTierCouponID,
CouponValue: couponValue,
CouponDuration: couponDuration,
CouponProjectLimit: couponProjectLimit,
MinCoinPayment: minCoinPayment,
AutoAdvance: config.AutoAdvance,
listingLimit: config.ListingLimit,
nowFn: time.Now,
@ -392,9 +379,7 @@ func (service *Service) GetRate(ctx context.Context, curr1, curr2 *monetary.Curr
return info1.RateBTC.Div(info2.RateBTC), nil
}
// PrepareInvoiceProjectRecords iterates through all projects and creates invoice records if
// none exists. Before creating invoice records, it ensures that all eligible users have a
// free tier coupon.
// PrepareInvoiceProjectRecords iterates through all projects and creates invoice records if none exist.
func (service *Service) PrepareInvoiceProjectRecords(ctx context.Context, period time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
@ -408,19 +393,18 @@ func (service *Service) PrepareInvoiceProjectRecords(ctx context.Context, period
return Error.New("allowed for past periods only")
}
var numberOfCustomers, numberOfRecords, numberOfCouponsUsages int
var numberOfCustomers, numberOfRecords int
customersPage, err := service.db.Customers().List(ctx, 0, service.listingLimit, end)
if err != nil {
return Error.Wrap(err)
}
numberOfCustomers += len(customersPage.Customers)
records, usages, err := service.processCustomers(ctx, customersPage.Customers, start, end)
records, err := service.processCustomers(ctx, customersPage.Customers, start, end)
if err != nil {
return Error.Wrap(err)
}
numberOfRecords += records
numberOfCouponsUsages += usages
for customersPage.Next {
if err = ctx.Err(); err != nil {
@ -432,101 +416,44 @@ func (service *Service) PrepareInvoiceProjectRecords(ctx context.Context, period
return Error.Wrap(err)
}
records, usages, err := service.processCustomers(ctx, customersPage.Customers, start, end)
records, err := service.processCustomers(ctx, customersPage.Customers, start, end)
if err != nil {
return Error.Wrap(err)
}
numberOfRecords += records
numberOfCouponsUsages += usages
}
service.log.Info("Number of processed entries.", zap.Int("Customers", numberOfCustomers), zap.Int("Projects", numberOfRecords), zap.Int("Coupons Usages", numberOfCouponsUsages))
service.log.Info("Number of processed entries.", zap.Int("Customers", numberOfCustomers), zap.Int("Projects", numberOfRecords))
return nil
}
func (service *Service) processCustomers(ctx context.Context, customers []Customer, start, end time.Time) (int, int, error) {
func (service *Service) processCustomers(ctx context.Context, customers []Customer, start, end time.Time) (int, error) {
var allRecords []CreateProjectRecord
var usages []CouponUsage
for _, customer := range customers {
projects, err := service.projectsDB.GetOwn(ctx, customer.UserID)
if err != nil {
return 0, 0, err
return 0, err
}
leftToCharge, records, err := service.createProjectRecords(ctx, customer.ID, projects, start, end)
records, err := service.createProjectRecords(ctx, customer.ID, projects, start, end)
if err != nil {
return 0, 0, err
return 0, err
}
allRecords = append(allRecords, records...)
coupons, err := service.db.Coupons().ListByUserIDAndStatus(ctx, customer.UserID, payments.CouponActive)
if err != nil {
return 0, 0, err
}
// Apply any promotional credits (a.k.a. coupons) on the remainder.
for _, coupon := range coupons {
if coupon.Status == payments.CouponExpired {
// this coupon has already been marked as expired.
continue
}
expirationDate := coupon.ExpirationDate()
if expirationDate != nil &&
end.After(*expirationDate) {
// this coupon is identified as expired for first time, mark it in the database
if _, err = service.db.Coupons().Update(ctx, coupon.ID, payments.CouponExpired); err != nil {
return 0, 0, err
}
continue
}
alreadyChargedAmount, err := service.db.Coupons().TotalUsage(ctx, coupon.ID)
if err != nil {
return 0, 0, err
}
remaining := coupon.Amount - alreadyChargedAmount
amountToChargeFromCoupon := leftToCharge
if amountToChargeFromCoupon >= remaining {
amountToChargeFromCoupon = remaining
}
if amountToChargeFromCoupon > 0 {
usages = append(usages, CouponUsage{
Period: start,
Amount: amountToChargeFromCoupon,
Status: CouponUsageStatusUnapplied,
CouponID: coupon.ID,
})
leftToCharge -= amountToChargeFromCoupon
}
if amountToChargeFromCoupon < remaining && expirationDate != nil && end.Equal(*expirationDate) {
// the coupon was not fully spent, but this is the last month
// it is valid for, so mark it as expired in database
if _, err = service.db.Coupons().Update(ctx, coupon.ID, payments.CouponExpired); err != nil {
return 0, 0, err
}
}
}
}
return len(allRecords), len(usages), service.db.ProjectRecords().Create(ctx, allRecords, usages, start, end)
return len(allRecords), service.db.ProjectRecords().Create(ctx, allRecords, start, end)
}
// createProjectRecords creates invoice project record if none exists.
func (service *Service) createProjectRecords(ctx context.Context, customerID string, projects []console.Project, start, end time.Time) (_ int64, _ []CreateProjectRecord, err error) {
func (service *Service) createProjectRecords(ctx context.Context, customerID string, projects []console.Project, start, end time.Time) (_ []CreateProjectRecord, err error) {
defer mon.Task()(&ctx)(&err)
var records []CreateProjectRecord
sumLeftToCharge := int64(0)
for _, project := range projects {
if err = ctx.Err(); err != nil {
return 0, nil, err
return nil, err
}
if err = service.db.ProjectRecords().Check(ctx, project.ID, start, end); err != nil {
@ -535,12 +462,12 @@ func (service *Service) createProjectRecords(ctx context.Context, customerID str
continue
}
return 0, nil, err
return nil, err
}
usage, err := service.usageDB.GetProjectTotal(ctx, project.ID, start, end)
if err != nil {
return 0, nil, err
return nil, err
}
// TODO: account for usage data.
@ -552,24 +479,9 @@ func (service *Service) createProjectRecords(ctx context.Context, customerID str
Objects: usage.ObjectCount,
},
)
leftToCharge := service.calculateProjectUsagePrice(usage.Egress, usage.Storage, usage.ObjectCount).TotalInt64()
if leftToCharge == 0 {
continue
}
// If there is a Stripe coupon applied for the project owner, apply its
// discount first before applying other credits of this user. This
// avoids the issue with negative totals in invoices.
leftToCharge, err = service.discountedProjectUsagePrice(ctx, customerID, leftToCharge)
if err != nil {
return 0, nil, err
}
sumLeftToCharge += leftToCharge
}
return sumLeftToCharge, records, nil
return records, nil
}
// InvoiceApplyProjectRecords iterates through unapplied invoice project records and creates invoice line items
@ -755,122 +667,6 @@ func (service *Service) ApplyFreeTierCoupons(ctx context.Context) (err error) {
return nil
}
// InvoiceApplyCoupons iterates through unapplied project coupons and creates invoice line items
// for stripe customer.
func (service *Service) InvoiceApplyCoupons(ctx context.Context, period time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
now := service.nowFn().UTC()
utc := period.UTC()
start := time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(utc.Year(), utc.Month()+1, 1, 0, 0, 0, 0, time.UTC)
if end.After(now) {
return Error.New("allowed for past periods only")
}
couponsUsages := 0
usagePage, err := service.db.Coupons().ListUnapplied(ctx, 0, service.listingLimit, start)
if err != nil {
return Error.Wrap(err)
}
if err = service.applyCoupons(ctx, usagePage.Usages); err != nil {
return Error.Wrap(err)
}
couponsUsages += len(usagePage.Usages)
for usagePage.Next {
if err = ctx.Err(); err != nil {
return Error.Wrap(err)
}
// we are always starting from offset 0 because applyCoupons is changing coupon usage state to applied
usagePage, err = service.db.Coupons().ListUnapplied(ctx, 0, service.listingLimit, start)
if err != nil {
return Error.Wrap(err)
}
if err = service.applyCoupons(ctx, usagePage.Usages); err != nil {
return Error.Wrap(err)
}
couponsUsages += len(usagePage.Usages)
}
service.log.Info("Number of processed coupons usages.", zap.Int("Coupons Usages", couponsUsages))
return nil
}
// applyCoupons applies concrete coupon usage as invoice line item.
func (service *Service) applyCoupons(ctx context.Context, usages []CouponUsage) (err error) {
defer mon.Task()(&ctx)(&err)
for _, usage := range usages {
if err = ctx.Err(); err != nil {
return err
}
coupon, err := service.db.Coupons().Get(ctx, usage.CouponID)
if err != nil {
return err
}
customerID, err := service.db.Customers().GetCustomerID(ctx, coupon.UserID)
if err != nil {
if errors.Is(err, ErrNoCustomer) {
service.log.Warn("Stripe customer does not exist for coupon owner.", zap.Stringer("User ID", coupon.UserID), zap.Stringer("Coupon ID", coupon.ID))
continue
}
return err
}
if err = service.createInvoiceCouponItems(ctx, coupon, usage, customerID); err != nil {
return err
}
}
return nil
}
// createInvoiceCouponItems consumes invoice project record and creates invoice line items for stripe customer.
func (service *Service) createInvoiceCouponItems(ctx context.Context, coupon payments.CouponOld, usage CouponUsage, customerID string) (err error) {
defer mon.Task()(&ctx, customerID, coupon)(&err)
err = service.db.Coupons().ApplyUsage(ctx, usage.CouponID, usage.Period)
if err != nil {
return err
}
totalUsage, err := service.db.Coupons().TotalUsage(ctx, coupon.ID)
if err != nil {
return err
}
if totalUsage == coupon.Amount {
_, err = service.db.Coupons().Update(ctx, coupon.ID, payments.CouponUsed)
if err != nil {
return err
}
}
projectItem := &stripe.InvoiceItemParams{
Amount: stripe.Int64(-usage.Amount),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(customerID),
Description: stripe.String(coupon.Description),
}
projectItem.AddMetadata("couponID", coupon.ID.String())
_, err = service.stripeClient.InvoiceItems().New(projectItem)
return err
}
// CreateInvoices lists through all customers and creates invoices.
func (service *Service) CreateInvoices(ctx context.Context, period time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
@ -1013,48 +809,6 @@ func (service *Service) calculateProjectUsagePrice(egress int64, storage, object
}
}
// discountedProjectUsagePrice reduces the project usage price with the discount applied for the Stripe customer.
// The promotional coupons and bonus credits are not applied yet.
func (service *Service) discountedProjectUsagePrice(ctx context.Context, customerID string, projectUsagePrice int64) (int64, error) {
customer, err := service.stripeClient.Customers().Get(customerID, nil)
if err != nil {
return 0, Error.Wrap(err)
}
if customer.Discount == nil {
return projectUsagePrice, nil
}
coupon := customer.Discount.Coupon
if coupon == nil {
return projectUsagePrice, nil
}
if !coupon.Valid {
return projectUsagePrice, nil
}
if coupon.AmountOff > 0 {
service.log.Info("Applying Stripe discount.", zap.String("Customer ID", customerID), zap.Int64("AmountOff", coupon.AmountOff))
discounted := projectUsagePrice - coupon.AmountOff
if discounted < 0 {
return 0, nil
}
return discounted, nil
}
if coupon.PercentOff > 0 {
service.log.Info("Applying Stripe discount.", zap.String("Customer ID", customerID), zap.Float64("PercentOff", coupon.PercentOff))
discount := int64(math.Round(float64(projectUsagePrice) * coupon.PercentOff / 100))
return projectUsagePrice - discount, nil
}
return projectUsagePrice, nil
}
// SetNow allows tests to have the Service act as if the current time is whatever
// they want. This avoids races and sleeping, making tests more reliable and efficient.
func (service *Service) SetNow(now func() time.Time) {

View File

@ -4,25 +4,21 @@
package stripecoinpayments_test
import (
"fmt"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/memory"
"storj.io/common/pb"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/metabase"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripecoinpayments"
)
@ -32,7 +28,6 @@ func TestService_InvoiceElementsProcessing(t *testing.T) {
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Payments.StripeCoinPayments.ListingLimit = 4
config.Payments.CouponValue = 5
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
@ -43,7 +38,7 @@ func TestService_InvoiceElementsProcessing(t *testing.T) {
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
numberOfProjects := 19
// generate test data, each user has one project, one coupon and some credits
// generate test data, each user has one project and some credits
for i := 0; i < numberOfProjects; i++ {
user, err := satellite.AddUser(ctx, console.CreateUser{
FullName: "testuser" + strconv.Itoa(i),
@ -73,11 +68,6 @@ func TestService_InvoiceElementsProcessing(t *testing.T) {
require.NoError(t, err)
require.Equal(t, numberOfProjects, len(recordsPage.Records))
// check if we have coupon for each project
couponsPage, err := satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
require.Equal(t, numberOfProjects, len(couponsPage.Usages))
err = satellite.API.Payments.Service.InvoiceApplyProjectRecords(ctx, period)
require.NoError(t, err)
@ -85,14 +75,6 @@ func TestService_InvoiceElementsProcessing(t *testing.T) {
recordsPage, err = satellite.DB.StripeCoinPayments().ProjectRecords().ListUnapplied(ctx, 0, 40, start, end)
require.NoError(t, err)
require.Equal(t, 0, len(recordsPage.Records))
err = satellite.API.Payments.Service.InvoiceApplyCoupons(ctx, period)
require.NoError(t, err)
// verify that we applied all unapplied coupons
couponsPage, err = satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
require.Equal(t, 0, len(couponsPage.Usages))
})
}
@ -169,10 +151,6 @@ func TestService_InvoiceUserWithManyProjects(t *testing.T) {
err = payments.Service.PrepareInvoiceProjectRecords(ctx, period)
require.NoError(t, err)
couponsPage, err := satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
require.Equal(t, 1, len(couponsPage.Usages))
for i := 0; i < len(projects); i++ {
projectRecord, err := satellite.DB.StripeCoinPayments().ProjectRecords().Get(ctx, projects[i].ID, start, end)
require.NoError(t, err)
@ -191,335 +169,11 @@ func TestService_InvoiceUserWithManyProjects(t *testing.T) {
err = payments.Service.InvoiceApplyProjectRecords(ctx, period)
require.NoError(t, err)
err = payments.Service.InvoiceApplyCoupons(ctx, period)
require.NoError(t, err)
err = payments.Service.CreateInvoices(ctx, period)
require.NoError(t, err)
})
}
func TestService_InvoiceUserWithManyCoupons(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Payments.CouponValue = 3
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
satellite := planet.Satellites[0]
paymentsAPI := satellite.API.Payments
// pick a specific date so that it doesn't fail if it's the last day of the month
// keep month + 1 because user needs to be created before calculation
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
paymentsAPI.Service.SetNow(func() time.Time {
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
})
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
storageHours := 24
user, err := satellite.AddUser(ctx, console.CreateUser{
FullName: "testuser",
Email: "user@test",
}, 5)
require.NoError(t, err)
project, err := satellite.AddProject(ctx, user.ID, "testproject")
require.NoError(t, err)
duration := 2
sumOfCoupons := int64(0)
for i := 0; i < 5; i++ {
coupon, err := satellite.API.Payments.Accounts.Coupons().Create(ctx, payments.CouponOld{
ID: testrand.UUID(),
UserID: user.ID,
Amount: int64(i + 4),
Duration: &duration,
Status: payments.CouponActive,
Type: payments.CouponTypePromotional,
})
require.NoError(t, err)
sumOfCoupons += coupon.Amount
}
{
// generate egress
err = satellite.DB.Orders().UpdateBucketBandwidthSettle(ctx, project.ID, []byte("testbucket"),
pb.PieceAction_GET, 10*memory.GiB.Int64(), 0, period)
require.NoError(t, err)
// generate storage
// we need at least two tallies across time to calculate storage
tally := &accounting.BucketTally{
BucketLocation: metabase.BucketLocation{
ProjectID: project.ID,
BucketName: "testbucket",
},
TotalBytes: memory.TiB.Int64(),
ObjectCount: 45,
}
tallies := map[metabase.BucketLocation]*accounting.BucketTally{
{}: tally,
}
err = satellite.DB.ProjectAccounting().SaveTallies(ctx, period, tallies)
require.NoError(t, err)
err = satellite.DB.ProjectAccounting().SaveTallies(ctx, period.Add(time.Duration(storageHours)*time.Hour), tallies)
require.NoError(t, err)
}
err = paymentsAPI.Service.PrepareInvoiceProjectRecords(ctx, period)
require.NoError(t, err)
// we should have usages for coupons: created with user + created in test
couponsPage, err := satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
require.Equal(t, 1+5, len(couponsPage.Usages))
coupons, err := satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, len(coupons), len(couponsPage.Usages))
var sumCoupons int64
var sumUsages int64
for i, coupon := range coupons {
sumCoupons += coupon.Amount
require.NotEqual(t, payments.CouponExpired, coupon.Status)
sumUsages += couponsPage.Usages[i].Amount
require.Equal(t, stripecoinpayments.CouponUsageStatusUnapplied, couponsPage.Usages[i].Status)
}
require.Equal(t, sumCoupons, sumUsages)
err = paymentsAPI.Service.InvoiceApplyCoupons(ctx, period)
require.NoError(t, err)
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, len(coupons), len(couponsPage.Usages))
for _, coupon := range coupons {
require.Equal(t, payments.CouponUsed, coupon.Status)
}
couponsPage, err = satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
require.Equal(t, 0, len(couponsPage.Usages))
})
}
func TestService_ApplyCouponsInTheOrder(t *testing.T) {
// apply coupons in the order of their expiration date
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Payments.CouponValue = 24
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
satellite := planet.Satellites[0]
paymentsAPI := satellite.API.Payments
// pick a specific date so that it doesn't fail if it's the last day of the month
// keep month + 1 because user needs to be created before calculation
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
paymentsAPI.Service.SetNow(func() time.Time {
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
})
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
user, err := satellite.AddUser(ctx, console.CreateUser{
FullName: "testuser",
Email: "user@test",
}, 5)
require.NoError(t, err)
project, err := satellite.AddProject(ctx, user.ID, "testproject")
require.NoError(t, err)
additionalCoupons := 3
// we will have coupons with duration 5, 4, 3 and 2 from coupon create with AddUser
for i := 0; i < additionalCoupons; i++ {
duration := additionalCoupons - i + 2
_, err = satellite.API.Payments.Accounts.Coupons().Create(ctx, payments.CouponOld{
ID: testrand.UUID(),
UserID: user.ID,
Amount: 24,
Duration: &duration,
Status: payments.CouponActive,
Type: payments.CouponTypePromotional,
})
require.NoError(t, err)
}
{
// generate egress - 48 cents
err = satellite.DB.Orders().UpdateBucketBandwidthSettle(ctx, project.ID, []byte("testbucket"),
pb.PieceAction_GET, 10*memory.GiB.Int64(), 0, period)
require.NoError(t, err)
}
err = paymentsAPI.Service.PrepareInvoiceProjectRecords(ctx, period)
require.NoError(t, err)
// we should have usages for 2 coupons for which left to charge will be 0
couponsPage, err := satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
require.Equal(t, 2, len(couponsPage.Usages))
err = paymentsAPI.Service.InvoiceApplyCoupons(ctx, period)
require.NoError(t, err)
usedCoupons, err := satellite.DB.StripeCoinPayments().Coupons().ListByUserIDAndStatus(ctx, user.ID, payments.CouponUsed)
require.NoError(t, err)
require.Equal(t, 2, len(usedCoupons))
// coupons with duration 2 and 3 should be used
for _, coupon := range usedCoupons {
require.NotNil(t, coupon.Duration)
require.Less(t, *coupon.Duration, 4)
}
activeCoupons, err := satellite.DB.StripeCoinPayments().Coupons().ListByUserIDAndStatus(ctx, user.ID, payments.CouponActive)
require.NoError(t, err)
require.Equal(t, 2, len(activeCoupons))
// coupons with duration 4 and 5 should be NOT used
for _, coupon := range activeCoupons {
require.NotNil(t, coupon.Duration)
require.Greater(t, *coupon.Duration, 3)
require.EqualValues(t, 24, coupon.Amount)
}
})
}
func TestService_CouponStatus(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
satellite := planet.Satellites[0]
for i, tt := range []struct {
duration int
amount int64
egress memory.Size
expectedStatus payments.CouponStatus
}{
{
duration: 2, // expires one month after billed period
amount: 100, // $1.00
egress: 0, // $0.00
expectedStatus: payments.CouponActive,
},
{
duration: 2, // expires one month after billed period
amount: 100, // $1.00
egress: 10 * memory.GB, // $0.45
expectedStatus: payments.CouponActive,
},
{
duration: 2, // expires one month after billed period
amount: 10, // $0.10
egress: 10 * memory.GB, // $0.45
expectedStatus: payments.CouponUsed,
},
{
duration: 1, // the billed period is the last valid month
amount: 100, // $1.00
egress: 0, // $0.00
expectedStatus: payments.CouponExpired,
},
{
duration: 1, // the billed period is the last valid month
amount: 100, // $1.00
egress: 10 * memory.GB, // $0.45
expectedStatus: payments.CouponExpired,
},
{
duration: 1, // the billed period is the last valid month
amount: 10, // $0.10
egress: 10 * memory.GB, // $0.45
expectedStatus: payments.CouponUsed,
},
{
duration: 0, // expired before the billed period
amount: 100, // $1.00
egress: 0, // $0.00
expectedStatus: payments.CouponExpired,
},
{
duration: 0, // expired before the billed period
amount: 100, // $1.00
egress: 10 * memory.GB, // $0.45
expectedStatus: payments.CouponExpired,
},
{
duration: 0, // expired before the billed period
amount: 10, // $0.10
egress: 10 * memory.GB, // $0.45
expectedStatus: payments.CouponExpired,
},
} {
errTag := fmt.Sprintf("%d. %+v", i, tt)
user, err := satellite.AddUser(ctx, console.CreateUser{
FullName: "testuser" + strconv.Itoa(i),
Email: "test@test" + strconv.Itoa(i),
}, 1)
require.NoError(t, err, errTag)
project, err := satellite.AddProject(ctx, user.ID, "testproject-"+strconv.Itoa(i))
require.NoError(t, err, errTag)
// Delete any automatically added coupons
coupons, err := satellite.API.Payments.Accounts.Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err, errTag)
for _, coupon := range coupons {
err = satellite.DB.StripeCoinPayments().Coupons().Delete(ctx, coupon.ID)
require.NoError(t, err, errTag)
}
// create a new coupon
_, err = satellite.API.Payments.Accounts.Coupons().Create(ctx, payments.CouponOld{
ID: testrand.UUID(),
UserID: user.ID,
Amount: tt.amount,
Duration: &tt.duration,
})
require.NoError(t, err, errTag)
// pick a specific date so that it doesn't fail if it's the last day of the month
// keep month + 1 because user needs to be created before calculation
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
// generate egress
err = satellite.DB.Orders().UpdateBucketBandwidthSettle(ctx, project.ID, []byte("testbucket"),
pb.PieceAction_GET, tt.egress.Int64(), 0, period)
require.NoError(t, err, errTag)
satellite.API.Payments.Service.SetNow(func() time.Time {
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
})
err = satellite.API.Payments.Service.PrepareInvoiceProjectRecords(ctx, period)
require.NoError(t, err, errTag)
err = satellite.API.Payments.Service.InvoiceApplyCoupons(ctx, period)
require.NoError(t, err, errTag)
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err, errTag)
require.Len(t, coupons, 1, errTag)
assert.Equal(t, tt.expectedStatus, coupons[0].Status, errTag)
}
})
}
func TestService_ProjectsWithMembers(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,

View File

@ -1,475 +0,0 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package satellitedb
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"time"
"github.com/zeebo/errs"
"storj.io/common/memory"
"storj.io/common/uuid"
"storj.io/private/dbutil/pgutil"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/stripecoinpayments"
"storj.io/storj/satellite/satellitedb/dbx"
)
// ensures that coupons implements payments.CouponsDB.
var _ stripecoinpayments.CouponsDB = (*coupons)(nil)
// coupons is an implementation of payments.CouponsDB.
//
// architecture: Database
type coupons struct {
db *satelliteDB
}
// Insert inserts a coupon into the database.
func (coupons *coupons) Insert(ctx context.Context, coupon payments.CouponOld) (_ payments.CouponOld, err error) {
defer mon.Task()(&ctx, coupon)(&err)
id, err := uuid.New()
if err != nil {
return payments.CouponOld{}, err
}
duration := 0
createFields := dbx.Coupon_Create_Fields{}
if coupon.Duration != nil {
duration = *coupon.Duration
createFields.BillingPeriods = dbx.Coupon_BillingPeriods(int64(duration))
}
cpx, err := coupons.db.Create_Coupon(
ctx,
dbx.Coupon_Id(id[:]),
dbx.Coupon_UserId(coupon.UserID[:]),
dbx.Coupon_Amount(coupon.Amount),
dbx.Coupon_Description(coupon.Description),
dbx.Coupon_Type(int(coupon.Type)),
dbx.Coupon_Status(int(coupon.Status)),
dbx.Coupon_Duration(int64(duration)),
createFields,
)
if err != nil {
return payments.CouponOld{}, err
}
return fromDBXCoupon(cpx)
}
// Update updates coupon in database.
func (coupons *coupons) Update(ctx context.Context, couponID uuid.UUID, status payments.CouponStatus) (_ payments.CouponOld, err error) {
defer mon.Task()(&ctx, couponID)(&err)
cpx, err := coupons.db.Update_Coupon_By_Id(
ctx,
dbx.Coupon_Id(couponID[:]),
dbx.Coupon_Update_Fields{
Status: dbx.Coupon_Status(int(status)),
},
)
if err != nil {
return payments.CouponOld{}, err
}
return fromDBXCoupon(cpx)
}
// Get returns coupon by ID.
func (coupons *coupons) Get(ctx context.Context, couponID uuid.UUID) (_ payments.CouponOld, err error) {
defer mon.Task()(&ctx, couponID)(&err)
dbxCoupon, err := coupons.db.Get_Coupon_By_Id(ctx, dbx.Coupon_Id(couponID[:]))
if err != nil {
return payments.CouponOld{}, err
}
return fromDBXCoupon(dbxCoupon)
}
// Delete removes a coupon from the database by its ID.
func (coupons *coupons) Delete(ctx context.Context, couponID uuid.UUID) (err error) {
defer mon.Task()(&ctx, couponID)(&err)
_, err = coupons.db.Delete_Coupon_By_Id(ctx, dbx.Coupon_Id(couponID[:]))
return err
}
// List returns all coupons of specified user.
func (coupons *coupons) ListByUserID(ctx context.Context, userID uuid.UUID) (_ []payments.CouponOld, err error) {
defer mon.Task()(&ctx, userID)(&err)
dbxCoupons, err := coupons.db.All_Coupon_By_UserId_OrderBy_Desc_CreatedAt(
ctx,
dbx.Coupon_UserId(userID[:]),
)
if err != nil {
return nil, err
}
return couponsFromDbxSlice(dbxCoupons)
}
// ListByUserIDAndStatus returns all coupons of specified user and status. Results are ordered (asc) by expiration date.
func (coupons *coupons) ListByUserIDAndStatus(ctx context.Context, userID uuid.UUID, status payments.CouponStatus) (_ []payments.CouponOld, err error) {
defer mon.Task()(&ctx, userID)(&err)
dbxCoupons, err := coupons.db.All_Coupon_By_UserId_And_Status_OrderBy_Desc_CreatedAt(
ctx,
dbx.Coupon_UserId(userID[:]),
dbx.Coupon_Status(int(status)),
)
if err != nil {
return nil, err
}
result, err := couponsFromDbxSlice(dbxCoupons)
if err != nil {
return nil, err
}
sort.Slice(result, func(i, k int) bool {
a := result[i].ExpirationDate()
b := result[k].ExpirationDate()
if a == nil && b == nil {
return false
}
if a == nil && b != nil {
return false
}
if a != nil && b == nil {
return true
}
return a.Before(*b)
})
return result, nil
}
// List returns all coupons with specified status.
func (coupons *coupons) List(ctx context.Context, status payments.CouponStatus) (_ []payments.CouponOld, err error) {
defer mon.Task()(&ctx, status)(&err)
dbxCoupons, err := coupons.db.All_Coupon_By_Status_OrderBy_Desc_CreatedAt(
ctx,
dbx.Coupon_Status(int(status)),
)
if err != nil {
return nil, err
}
return couponsFromDbxSlice(dbxCoupons)
}
// ListPending returns paginated list of pending transactions.
func (coupons *coupons) ListPaged(ctx context.Context, offset int64, limit int, before time.Time, status payments.CouponStatus) (_ payments.CouponsPage, err error) {
defer mon.Task()(&ctx)(&err)
var page payments.CouponsPage
dbxCoupons, err := coupons.db.Limited_Coupon_By_CreatedAt_LessOrEqual_And_Status_OrderBy_Desc_CreatedAt(
ctx,
dbx.Coupon_CreatedAt(before.UTC()),
dbx.Coupon_Status(coinpayments.StatusPending.Int()),
limit+1,
offset,
)
if err != nil {
return payments.CouponsPage{}, err
}
if len(dbxCoupons) == limit+1 {
page.Next = true
page.NextOffset = offset + int64(limit) + 1
dbxCoupons = dbxCoupons[:len(dbxCoupons)-1]
}
page.Coupons, err = couponsFromDbxSlice(dbxCoupons)
if err != nil {
return payments.CouponsPage{}, nil
}
return page, nil
}
// fromDBXCoupon converts *dbx.Coupon to *payments.Coupon.
func fromDBXCoupon(dbxCoupon *dbx.Coupon) (coupon payments.CouponOld, err error) {
coupon.UserID, err = uuid.FromBytes(dbxCoupon.UserId)
if err != nil {
return payments.CouponOld{}, err
}
coupon.ID, err = uuid.FromBytes(dbxCoupon.Id)
if err != nil {
return payments.CouponOld{}, err
}
if dbxCoupon.BillingPeriods != nil {
duration := int(*dbxCoupon.BillingPeriods)
coupon.Duration = &duration
}
coupon.Description = dbxCoupon.Description
coupon.Amount = dbxCoupon.Amount
coupon.Created = dbxCoupon.CreatedAt
coupon.Status = payments.CouponStatus(dbxCoupon.Status)
return coupon, nil
}
// AddUsage creates new coupon usage record in database.
func (coupons *coupons) AddUsage(ctx context.Context, usage stripecoinpayments.CouponUsage) (err error) {
defer mon.Task()(&ctx, usage)(&err)
_, err = coupons.db.Create_CouponUsage(
ctx,
dbx.CouponUsage_CouponId(usage.CouponID[:]),
dbx.CouponUsage_Amount(usage.Amount),
dbx.CouponUsage_Status(int(usage.Status)),
dbx.CouponUsage_Period(usage.Period),
)
return err
}
// TotalUsage gets sum of all usage records for specified coupon.
func (coupons *coupons) TotalUsage(ctx context.Context, couponID uuid.UUID) (_ int64, err error) {
defer mon.Task()(&ctx, couponID)(&err)
query := coupons.db.Rebind(
`SELECT COALESCE(SUM(amount), 0)
FROM coupon_usages
WHERE coupon_id = ?;`,
)
amountRow := coupons.db.QueryRowContext(ctx, query, couponID[:])
var amount int64
err = amountRow.Scan(&amount)
return amount, err
}
// TotalUsage gets sum of all usage records for specified coupon.
func (coupons *coupons) TotalUsageForPeriod(ctx context.Context, couponID uuid.UUID, period time.Time) (_ int64, err error) {
defer mon.Task()(&ctx, couponID)(&err)
query := coupons.db.Rebind(
`SELECT COALESCE(SUM(amount), 0)
FROM coupon_usages
WHERE coupon_id = ?;`,
)
amountRow := coupons.db.QueryRowContext(ctx, query, couponID[:])
var amount int64
err = amountRow.Scan(&amount)
return amount, err
}
// GetLatest return period_end of latest coupon charge.
func (coupons *coupons) GetLatest(ctx context.Context, couponID uuid.UUID) (_ time.Time, err error) {
defer mon.Task()(&ctx, couponID)(&err)
query := coupons.db.Rebind(
`SELECT period
FROM coupon_usages
WHERE coupon_id = ?
ORDER BY period DESC
LIMIT 1;`,
)
amountRow := coupons.db.QueryRowContext(ctx, query, couponID[:])
var created time.Time
err = amountRow.Scan(&created)
if errors.Is(err, sql.ErrNoRows) {
return created, stripecoinpayments.ErrNoCouponUsages.Wrap(err)
}
return created, err
}
// ListUnapplied returns coupon usage page with unapplied coupon usages.
func (coupons *coupons) ListUnapplied(ctx context.Context, offset int64, limit int, period time.Time) (_ stripecoinpayments.CouponUsagePage, err error) {
defer mon.Task()(&ctx, offset, limit, period)(&err)
var page stripecoinpayments.CouponUsagePage
dbxRecords, err := coupons.db.Limited_CouponUsage_By_Period_And_Status_Equal_Number(
ctx,
dbx.CouponUsage_Period(period),
limit+1,
offset,
)
if err != nil {
return stripecoinpayments.CouponUsagePage{}, err
}
if len(dbxRecords) == limit+1 {
page.Next = true
page.NextOffset = offset + int64(limit) + 1
dbxRecords = dbxRecords[:len(dbxRecords)-1]
}
for _, dbxRecord := range dbxRecords {
record, err := couponUsageFromDbxSlice(dbxRecord)
if err != nil {
return stripecoinpayments.CouponUsagePage{}, err
}
page.Usages = append(page.Usages, record)
}
return page, nil
}
// ApplyUsage applies coupon usage and updates its status.
func (coupons *coupons) ApplyUsage(ctx context.Context, couponID uuid.UUID, period time.Time) (err error) {
defer mon.Task()(&ctx, couponID, period)(&err)
_, err = coupons.db.Update_CouponUsage_By_CouponId_And_Period(
ctx,
dbx.CouponUsage_CouponId(couponID[:]),
dbx.CouponUsage_Period(period),
dbx.CouponUsage_Update_Fields{
Status: dbx.CouponUsage_Status(int(stripecoinpayments.CouponUsageStatusApplied)),
},
)
return err
}
// couponsFromDbxSlice is used for creating []payments.Coupon entities from autogenerated []dbx.Coupon struct.
func couponsFromDbxSlice(couponsDbx []*dbx.Coupon) (_ []payments.CouponOld, err error) {
var coupons = make([]payments.CouponOld, 0)
var errors []error
// Generating []dbo from []dbx and collecting all errors
for _, couponDbx := range couponsDbx {
coupon, err := fromDBXCoupon(couponDbx)
if err != nil {
errors = append(errors, err)
continue
}
coupons = append(coupons, coupon)
}
return coupons, errs.Combine(errors...)
}
// couponUsageFromDbxSlice is used for creating stripecoinpayments.CouponUsage entity from autogenerated dbx.CouponUsage struct.
func couponUsageFromDbxSlice(couponUsageDbx *dbx.CouponUsage) (usage stripecoinpayments.CouponUsage, err error) {
usage.Status = stripecoinpayments.CouponUsageStatus(couponUsageDbx.Status)
usage.Period = couponUsageDbx.Period
usage.Amount = couponUsageDbx.Amount
usage.CouponID, err = uuid.FromBytes(couponUsageDbx.CouponId)
if err != nil {
return stripecoinpayments.CouponUsage{}, err
}
return usage, err
}
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have a project
// and do not have a promotional coupon yet. And updates project limits to selected size.
// If projectLimit is 0, project limits are not updated.
func (coupons *coupons) PopulatePromotionalCoupons(ctx context.Context, users []uuid.UUID, duration *int, amount int64, projectLimit memory.Size) (err error) {
defer mon.Task()(&ctx, users, duration, amount, projectLimit)(&err)
ids, err := coupons.activeUserWithProjectAndWithoutCoupon(ctx, users)
if err != nil {
return err
}
return coupons.db.WithTx(ctx, func(ctx context.Context, tx *dbx.Tx) error {
for _, id := range ids {
_, err = coupons.Insert(ctx, payments.CouponOld{
UserID: id.UserID,
Amount: amount,
Duration: duration,
Description: fmt.Sprintf("Promotional credits (limited time - %d billing periods)", duration),
Type: payments.CouponTypePromotional,
Status: payments.CouponActive,
})
if err != nil {
return err
}
// if projectLimit specified, set it, else omit change the existing value
if projectLimit.Int64() > 0 {
_, err = coupons.db.Update_Project_By_Id(ctx,
dbx.Project_Id(id.ProjectID[:]),
dbx.Project_Update_Fields{
UsageLimit: dbx.Project_UsageLimit(projectLimit.Int64()),
},
)
}
if err != nil {
return err
}
}
return nil
})
}
type userAndProject struct {
UserID uuid.UUID
ProjectID uuid.UUID
}
func (coupons *coupons) activeUserWithProjectAndWithoutCoupon(ctx context.Context, users []uuid.UUID) (ids []userAndProject, err error) {
var userIDs [][]byte
for i := range users {
userIDs = append(userIDs, users[i][:])
}
rows, err := coupons.db.QueryContext(ctx, coupons.db.Rebind(`
SELECT users_with_projects.id, users_with_projects.project_id
FROM (
SELECT selected_users.id, first_proj.id AS project_id
FROM (
SELECT id, status
FROM users
WHERE id = any(?)
) AS selected_users
INNER JOIN (
SELECT DISTINCT ON (owner_id) owner_id, id
FROM projects
ORDER BY owner_id, created_at ASC
) AS first_proj
ON selected_users.id = first_proj.owner_id
WHERE selected_users.status = ?
) AS users_with_projects
WHERE users_with_projects.id NOT IN (
SELECT user_id FROM coupons WHERE type = ? AND status = ?
)
`), pgutil.ByteaArray(userIDs), console.Active, payments.CouponTypePromotional, payments.CouponActive)
if err != nil {
return nil, err
}
defer func() { err = errs.Combine(err, rows.Close()) }()
for rows.Next() {
var id userAndProject
err = rows.Scan(&id.UserID, &id.ProjectID)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}

View File

@ -42,7 +42,7 @@ type invoiceProjectRecords struct {
}
// Create creates new invoice project record in the DB.
func (db *invoiceProjectRecords) Create(ctx context.Context, records []stripecoinpayments.CreateProjectRecord, couponUsages []stripecoinpayments.CouponUsage, start, end time.Time) (err error) {
func (db *invoiceProjectRecords) Create(ctx context.Context, records []stripecoinpayments.CreateProjectRecord, start, end time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
return db.db.WithTx(ctx, func(ctx context.Context, tx *dbx.Tx) error {
@ -67,19 +67,6 @@ func (db *invoiceProjectRecords) Create(ctx context.Context, records []stripecoi
}
}
for _, couponUsage := range couponUsages {
_, err = db.db.Create_CouponUsage(
ctx,
dbx.CouponUsage_CouponId(couponUsage.CouponID[:]),
dbx.CouponUsage_Amount(couponUsage.Amount),
dbx.CouponUsage_Status(int(couponUsage.Status)),
dbx.CouponUsage_Period(couponUsage.Period),
)
if err != nil {
return err
}
}
return nil
})
}

View File

@ -31,8 +31,3 @@ func (db *stripeCoinPaymentsDB) Transactions() stripecoinpayments.TransactionsDB
func (db *stripeCoinPaymentsDB) ProjectRecords() stripecoinpayments.ProjectRecordsDB {
return &invoiceProjectRecords{db: db.db}
}
// Coupons is getter for coupons db.
func (db *stripeCoinPaymentsDB) Coupons() stripecoinpayments.CouponsDB {
return &coupons{db: db.db}
}

View File

@ -514,21 +514,9 @@ identity.key-path: /root/.local/share/storj/identity/satellite/identity.key
# amount of percents that user will earn as bonus credits by depositing in STORJ tokens
# payments.bonus-rate: 10
# duration a new coupon is valid in months/billing cycles. An empty string means the coupon never expires
# payments.coupon-duration: "1"
# project limit to which increase to after applying the coupon, 0 B means not changing it from the default
# payments.coupon-project-limit: 0 B
# coupon value in cents
# payments.coupon-value: 165
# price user should pay for each TB of egress
# payments.egress-tb-price: "7"
# minimum value of coin payments in cents before coupon is applied
# payments.min-coin-payment: 1000
# price node receive for storing TB of audit in cents
# payments.node-audit-bandwidth-price: 1000