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:
parent
1ef06fae99
commit
3b751a35c5
@ -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.
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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())
|
||||
|
@ -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)))
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
}
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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}
|
||||
}
|
||||
|
12
scripts/testdata/satellite-config.yaml.lock
vendored
12
scripts/testdata/satellite-config.yaml.lock
vendored
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user