diff --git a/api/api_test.go b/api/api_test.go index 4c4ce39..606e0f1 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -109,7 +109,12 @@ func TestMain(m *testing.M) { // set reset db env var to true _ = os.Setenv("VOCDONI_MONGO_RESET_DB", "true") // create a new MongoDB connection with the test database - if testDB, err = db.New(mongoURI, test.RandomDatabaseName(), "subscriptions.json"); err != nil { + // create a new MongoDB connection with the test database + plans, err := db.ReadPlanJSON() + if err != nil { + panic(err) + } + if testDB, err = db.New(mongoURI, test.RandomDatabaseName(), plans); err != nil { panic(err) } defer testDB.Close() diff --git a/api/docs.md b/api/docs.md index 92c9613..30b6345 100644 --- a/api/docs.md +++ b/api/docs.md @@ -848,6 +848,16 @@ This request can be made only by organization admins. "smsNotification":false } }, + "censusSizeTiers": [ + { + "flatAmount":9900, + "upTo":100 + }, + { + "flatAmount":79900, + "upTo":1500 + } + ], ... ] } @@ -882,7 +892,17 @@ This request can be made only by organization admins. "personalization":false, "emailReminder":true, "smsNotification":false - } + }, + "censusSizeTiers": [ + { + "flatAmount":9900, + "upTo":100 + }, + { + "flatAmount":79900, + "upTo":1500 + } + ], } ``` diff --git a/assets/subscriptions.json b/assets/plans.json similarity index 72% rename from assets/subscriptions.json rename to assets/plans.json index da9c3e7..cb6849b 100644 --- a/assets/subscriptions.json +++ b/assets/plans.json @@ -1,12 +1,14 @@ [ { "ID": 1, - "Name": "Basic", - "StripeID": "prod_R3LTVsjklmuQAL", + "Name": "Essential Annual Plan", + "StripeID": "price_1QBEmzDW6VLep8WGpkwjynXV", + "StartingPrice": 9900, "Default": false, "Organization": { "Memberships": 5, - "SubOrgs": 1 + "SubOrgs": 1, + "CensusSize": 0 }, "VotingTypes": { "Approval": true, @@ -21,12 +23,14 @@ }, { "ID": 2, - "Name": "Pro", - "StripeID": "prod_R0kTryoMNl8I19", + "Name": "Premium Annual Plan", + "StripeID": "price_1Q8iyUDW6VLep8WGWXdjC78r", + "StartingPrice": 30000, "Default": false, "Organization": { "Memberships": 10, - "SubOrgs": 5 + "SubOrgs": 5, + "CensusSize": 0 }, "VotingTypes": { "Approval": true, @@ -41,9 +45,10 @@ }, { "ID": 3, - "Name": "free", + "Name": "Free Plan", + "StripeID": "price_1QMtoJDW6VLep8WGC2vsJ2CV", + "StartingPrice": 0, "Default": true, - "StripeID": "stripe_789", "Organization": { "Memberships": 10, "SubOrgs": 5, diff --git a/cmd/service/main.go b/cmd/service/main.go index 2572ed7..9b1379e 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -31,7 +31,6 @@ func main() { flag.StringP("privateKey", "k", "", "private key for the Vocdoni account") flag.BoolP("fullTransparentMode", "a", false, "allow all transactions and do not modify any of them") flag.String("emailTemplatesPath", "./assets", "path to the email templates") - flag.String("plansFile", "subscriptions.json", "JSON file that contains the subscriptions info") flag.String("smtpServer", "", "SMTP server") flag.Int("smtpPort", 587, "SMTP port") flag.String("smtpUsername", "", "SMTP username") @@ -60,7 +59,6 @@ func main() { // MongoDB vars mongoURL := viper.GetString("mongoURL") mongoDB := viper.GetString("mongoDB") - plansFile := viper.GetString("plansFile") // email vars emailTemplatesPath := viper.GetString("emailTemplatesPath") smtpServer := viper.GetString("smtpServer") @@ -74,8 +72,20 @@ func main() { stripeWebhookSecret := viper.GetString("stripeWebhookSecret") log.Init("debug", "stdout", os.Stderr) + // create Stripe client and include it in the API configuration + var stripeClient *stripe.StripeClient + if stripeApiSecret != "" || stripeWebhookSecret != "" { + stripeClient = stripe.New(stripeApiSecret, stripeWebhookSecret) + } else { + log.Fatalf("stripeApiSecret and stripeWebhookSecret are required") + } + availablePlans, err := stripeClient.GetPlans() + if err != nil || len(availablePlans) == 0 { + log.Fatalf("could not get the available plans: %v", err) + } + // initialize the MongoDB database - database, err := db.New(mongoURL, mongoDB, plansFile) + database, err := db.New(mongoURL, mongoDB, availablePlans) if err != nil { log.Fatalf("could not create the MongoDB database: %v", err) } @@ -107,6 +117,7 @@ func main() { Account: acc, WebAppURL: webURL, FullTransparentMode: fullTransparentMode, + StripeClient: stripeClient, } // overwrite the email notifications service with the SMTP service if the // required parameters are set and include it in the API configuration @@ -134,12 +145,6 @@ func main() { } log.Infow("email service created", "from", fmt.Sprintf("%s <%s>", emailFromName, emailFromAddress)) } - // create Stripe client and include it in the API configuration - if stripeApiSecret != "" || stripeWebhookSecret != "" { - apiConf.StripeClient = stripe.New(stripeApiSecret, stripeWebhookSecret) - } else { - log.Fatalf("stripeApiSecret and stripeWebhookSecret are required") - } subscriptions := subscriptions.New(&subscriptions.SubscriptionsConfig{ DB: database, }) diff --git a/db/helpers.go b/db/helpers.go index 9c742e1..9f55eb3 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -26,11 +26,6 @@ func (ms *MongoStorage) initCollections(database string) error { return err } log.Infow("current collections", "collections", currentCollections) - log.Infow("reading plans from file %s", ms.plansFile) - loadedPlans, err := readPlanJSON(ms.plansFile) - if err != nil { - return err - } // aux method to get a collection if it exists, or create it if it doesn't getCollection := func(name string) (*mongo.Collection, error) { alreadyCreated := false @@ -70,11 +65,11 @@ func (ms *MongoStorage) initCollections(database string) error { } if name == "plans" { var plans []interface{} - for _, plan := range loadedPlans { + for _, plan := range ms.stripePlans { plans = append(plans, plan) } count, err := ms.client.Database(database).Collection(name).InsertMany(ctx, plans) - if err != nil || len(count.InsertedIDs) != len(loadedPlans) { + if err != nil || len(count.InsertedIDs) != len(ms.stripePlans) { return nil, fmt.Errorf("failed to insert plans: %w", err) } } @@ -224,21 +219,11 @@ func dynamicUpdateDocument(item interface{}, alwaysUpdateTags []string) (bson.M, // readPlanJSON reads a JSON file with an array of subscritpions // and return it as a Plan array -func readPlanJSON(plansFile string) ([]*Plan, error) { - log.Warnf("Reading subscriptions from %s", plansFile) - file, err := root.Assets.Open(fmt.Sprintf("assets/%s", plansFile)) +func ReadPlanJSON() ([]*Plan, error) { + file, err := root.Assets.Open("assets/plans.json") if err != nil { return nil, err } - // file, err := os.Open(plansFile) - // if err != nil { - // return nil, err - // } - // defer func() { - // if err := file.Close(); err != nil { - // log.Warnw("failed to close subscriptions file", "error", err) - // } - // }() // Create a JSON decoder decoder := json.NewDecoder(file) diff --git a/db/mongo.go b/db/mongo.go index 039fa99..89fc6c8 100644 --- a/db/mongo.go +++ b/db/mongo.go @@ -17,10 +17,10 @@ import ( // MongoStorage uses an external MongoDB service for stoting the user data and election details. type MongoStorage struct { - database string - client *mongo.Client - keysLock sync.RWMutex - plansFile string + database string + client *mongo.Client + keysLock sync.RWMutex + stripePlans []*Plan users *mongo.Collection verifications *mongo.Collection @@ -34,7 +34,7 @@ type Options struct { Database string } -func New(url, database, plansFile string) (*MongoStorage, error) { +func New(url, database string, plans []*Plan) (*MongoStorage, error) { var err error ms := &MongoStorage{} if url == "" { @@ -67,7 +67,7 @@ func New(url, database, plansFile string) (*MongoStorage, error) { // init the database client ms.client = client ms.database = database - ms.plansFile = plansFile + ms.stripePlans = plans // init the collections if err := ms.initCollections(ms.database); err != nil { return nil, err diff --git a/db/mongo_test.go b/db/mongo_test.go index 93864f8..f8b361b 100644 --- a/db/mongo_test.go +++ b/db/mongo_test.go @@ -27,7 +27,11 @@ func TestMain(m *testing.M) { // set reset db env var to true _ = os.Setenv("VOCDONI_MONGO_RESET_DB", "true") // create a new MongoDB connection with the test database - db, err = New(mongoURI, test.RandomDatabaseName(), "subscriptions.json") + plans, err := ReadPlanJSON() + if err != nil { + panic(err) + } + db, err = New(mongoURI, test.RandomDatabaseName(), plans) if err != nil { panic(err) } diff --git a/db/types.go b/db/types.go index da6d72d..d33a916 100644 --- a/db/types.go +++ b/db/types.go @@ -80,13 +80,20 @@ type Features struct { } type Plan struct { - ID uint64 `json:"id" bson:"_id"` - Name string `json:"name" bson:"name"` - StripeID string `json:"stripeID" bson:"stripeID"` - Default bool `json:"default" bson:"default"` - Organization PlanLimits `json:"organization" bson:"organization"` - VotingTypes VotingTypes `json:"votingTypes" bson:"votingTypes"` - Features Features `json:"features" bson:"features"` + ID uint64 `json:"id" bson:"_id"` + Name string `json:"name" bson:"name"` + StripeID string `json:"stripeID" bson:"stripeID"` + StartingPrice int64 `json:"startingPrice" bson:"startingPrice"` + Default bool `json:"default" bson:"default"` + Organization PlanLimits `json:"organization" bson:"organization"` + VotingTypes VotingTypes `json:"votingTypes" bson:"votingTypes"` + Features Features `json:"features" bson:"features"` + CensusSizeTiers []PlanTier `json:"censusSizeTiers" bson:"censusSizeTiers"` +} + +type PlanTier struct { + Amount int64 `json:"Amount" bson:"Amount"` + UpTo int64 `json:"upTo" bson:"upTo"` } type OrganizationSubscription struct { diff --git a/stripe/stripe.go b/stripe/stripe.go index 9baa77e..cf17a29 100644 --- a/stripe/stripe.go +++ b/stripe/stripe.go @@ -2,13 +2,22 @@ package stripe import ( "encoding/json" + "fmt" "github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81/customer" + "github.com/stripe/stripe-go/v81/price" "github.com/stripe/stripe-go/v81/webhook" + "github.com/vocdoni/saas-backend/db" "go.vocdoni.io/dvote/log" ) +var PricesLookupKeys = []string{ + "essential_annual_plan", + "premium_annual_plan", + "free_plan", +} + // StripeClient is a client for interacting with the Stripe API. // It holds the necessary configuration such as the webhook secret. type StripeClient struct { @@ -57,3 +66,72 @@ func (s *StripeClient) GetInfoFromEvent(event stripe.Event) (*stripe.Customer, * } return customer, &subscription, nil } + +func (s *StripeClient) GetPriceByID(priceID string) *stripe.Price { + params := &stripe.PriceSearchParams{ + SearchParams: stripe.SearchParams{ + Query: fmt.Sprintf("active:'true' AND lookup_key:'%s'", priceID), + }, + } + params.AddExpand("data.tiers") + if results := price.Search(params); results.Next() { + return results.Price() + } + return nil +} + +func (s *StripeClient) GetPrices(priceIDs []string) []*stripe.Price { + var prices []*stripe.Price + for _, priceID := range priceIDs { + if price := s.GetPriceByID(priceID); price != nil { + prices = append(prices, price) + } + } + return prices +} + +func (s *StripeClient) GetPlans() ([]*db.Plan, error) { + var plans []*db.Plan + for i, priceID := range PricesLookupKeys { + if price := s.GetPriceByID(priceID); price != nil { + var organizationData db.PlanLimits + if err := json.Unmarshal([]byte(price.Metadata["Organization"]), &organizationData); err != nil { + return nil, fmt.Errorf("error parsing plan organization metadata JSON: %s\n", err.Error()) + } + var votingTypesData db.VotingTypes + if err := json.Unmarshal([]byte(price.Metadata["VotingTypes"]), &votingTypesData); err != nil { + return nil, fmt.Errorf("error parsing plan voting types metadata JSON: %s\n", err.Error()) + } + var featuresData db.Features + if err := json.Unmarshal([]byte(price.Metadata["Features"]), &featuresData); err != nil { + return nil, fmt.Errorf("error parsing plan features metadata JSON: %s\n", err.Error()) + } + startingPrice := price.UnitAmount + if len(price.Tiers) > 0 { + startingPrice = price.Tiers[0].FlatAmount + } + var tiers []db.PlanTier + for _, tier := range price.Tiers { + if tier.UpTo == 0 { + continue + } + tiers = append(tiers, db.PlanTier{ + Amount: tier.FlatAmount, + UpTo: tier.UpTo, + }) + } + plans = append(plans, &db.Plan{ + ID: uint64(i), + Name: price.Nickname, + StartingPrice: startingPrice, + StripeID: price.ID, + Default: price.Metadata["Default"] == "true", + Organization: organizationData, + VotingTypes: votingTypesData, + Features: featuresData, + CensusSizeTiers: tiers, + }) + } + } + return plans, nil +}