diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c0735ac..ffac0d2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - name: Install Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: go-version: 1.22.x cache: true @@ -15,14 +15,12 @@ jobs: # run: | # curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.30.0 # $(go env GOPATH)/bin/golangci-lint run --timeout=5m -c .golangci.yml - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v6 ### golangci-lint will take much time if loading multiple linters in .golangci.yml with: version: latest args: --timeout 5m --verbose - skip-cache: false - skip-pkg-cache: false - skip-build-cache: false + skip-cache: true only-new-issues: true test: @@ -31,7 +29,7 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - name: Set up Go environment - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: "1.22.x" - name: Tidy go module diff --git a/api/api.go b/api/api.go index f50f303..c06ea19 100644 --- a/api/api.go +++ b/api/api.go @@ -12,6 +12,8 @@ import ( "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/db" "github.com/vocdoni/saas-backend/notifications" + "github.com/vocdoni/saas-backend/stripe" + "github.com/vocdoni/saas-backend/subscriptions" "go.vocdoni.io/dvote/apiclient" "go.vocdoni.io/dvote/log" ) @@ -33,6 +35,10 @@ type APIConfig struct { // FullTransparentMode if true allows signing all transactions and does not // modify any of them. FullTransparentMode bool + // Stripe secrets + StripeClient *stripe.StripeClient + // Subscriptions permissions manager + Subscriptions *subscriptions.Subscriptions } // API type represents the API HTTP server with JWT authentication capabilities. @@ -47,6 +53,8 @@ type API struct { mail notifications.NotificationService secret string transparentMode bool + stripe *stripe.StripeClient + subscriptions *subscriptions.Subscriptions } // New creates a new API HTTP server. It does not start the server. Use Start() for that. @@ -64,6 +72,8 @@ func New(conf *APIConfig) *API { mail: conf.MailService, secret: conf.Secret, transparentMode: conf.FullTransparentMode, + stripe: conf.StripeClient, + subscriptions: conf.Subscriptions, } } @@ -128,6 +138,9 @@ func (a *API) initRouter() http.Handler { // update the organization log.Infow("new route", "method", "PUT", "path", organizationEndpoint) r.Put(organizationEndpoint, a.updateOrganizationHandler) + // get organization subscription + log.Infow("new route", "method", "GET", "path", organizationSubscriptionEndpoint) + r.Get(organizationSubscriptionEndpoint, a.getOrganizationSubscriptionHandler) // invite a new admin member to the organization log.Infow("new route", "method", "POST", "path", organizationAddMemberEndpoint) r.Post(organizationAddMemberEndpoint, a.inviteOrganizationMemberHandler) @@ -179,6 +192,14 @@ func (a *API) initRouter() http.Handler { // get organization types log.Infow("new route", "method", "GET", "path", organizationTypesEndpoint) r.Get(organizationTypesEndpoint, a.organizationsTypesHandler) + // get subscriptions + log.Infow("new route", "method", "GET", "path", plansEndpoint) + r.Get(plansEndpoint, a.getPlansHandler) + // get subscription info + log.Infow("new route", "method", "GET", "path", planInfoEndpoint) + r.Get(planInfoEndpoint, a.planInfoHandler) + log.Infow("new route", "method", "POST", "path", subscriptionsWebhook) + r.Post(subscriptionsWebhook, a.handleWebhook) }) a.router = r return r diff --git a/api/api_test.go b/api/api_test.go index a227759..4c4ce39 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -109,7 +109,7 @@ 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()); err != nil { + if testDB, err = db.New(mongoURI, test.RandomDatabaseName(), "subscriptions.json"); err != nil { panic(err) } defer testDB.Close() diff --git a/api/docs.md b/api/docs.md index 5933883..f5ace3d 100644 --- a/api/docs.md +++ b/api/docs.md @@ -29,8 +29,12 @@ - [🧑‍💼 Invite organization member](#-invite-organization-member) - [⏳ List pending invitations](#-list-pending-invitations) - [🤝 Accept organization invitation](#-accept-organization-invitation) + - [💸 Organization Subscription Info](#-organization-subscription-info) - [🤠 Available organization members roles](#-available-organization-members-roles) - [🏛️ Available organization types](#-available-organization-types) +- [🏦 Plans](#-plans) + - [🛒 Get Available Plans](#-get-plans) + - [🛍️ Get Plan Info](#-get-plan-info) @@ -316,7 +320,15 @@ This endpoint only returns the addresses of the organizations where the current "active": true, "parent": { "...": "..." - } + }, + "subscription":{ + "PlanID":3, + "StartDate":"2024-11-07T15:25:49.218Z", + "EndDate":"0001-01-01T00:00:00Z", + "RenewalDate":"0001-01-01T00:00:00Z", + "Active":true, + "MaxCensusSize":10 + }, } } ] @@ -662,6 +674,61 @@ Only the following parameters can be changed. Every parameter is optional. | `409` | `40901` | `duplicate conflict` | | `500` | `50002` | `internal server error` | +### 💸 Organization subscription info + +* **Path** `/organizations/{address}/subscription` +* **Method** `GET` +* **Request** +```json +{ + "subscriptionDetails":{ + "planID":3, + "startDate":"2024-11-07T15:25:49.218Z", + "endDate":"0001-01-01T00:00:00Z", + "renewalDate":"0001-01-01T00:00:00Z", + "active":true, + "maxCensusSize":10 + }, + "usage":{ + "sentSMS":0, + "sentEmails":0, + "subOrgs":0, + "members":0 + }, + "plan":{ + "id":3, + "name":"free", + "stripeID":"stripe_789", + "default":true, + "organization":{ + "memberships":10, + "subOrgs":5, + "censusSize":10 + }, + "votingTypes":{ + "approval":false, + "ranked":false, + "weighted":true + }, + "features":{ + "personalization":false, + "emailReminder":false, + "smsNotification":false + } + } +} +``` +This request can be made only by organization admins. + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40009` | `organization not found` | +| `400` | `40011` | `no organization provided` | +| `500` | `50002` | `internal server error` | + ### 🤠 Available organization members roles * **Path** `/organizations/roles` * **Method** `GET` @@ -754,3 +821,78 @@ Only the following parameters can be changed. Every parameter is optional. ] } ``` + +## 🏦 Plans + +### 🛒 Get Plans + +* **Path** `/plans` +* **Method** `GET` +* **Response** +```json +{ + "plans": [ + { + "id":1, + "name":"Basic", + "stripeID":"stripe_123", + "organization":{ + "memberships":1, + "subOrgs":1 + }, + "votingTypes":{ + "approval":true, + "ranked":true, + "weighted":true + }, + "features":{ + "personalization":false, + "emailReminder":true, + "smsNotification":false + } + }, + ... + ] +} +``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `500` | `50002` | `internal server error` | + +### 🛍️ Get Plan info + +* **Path** `/plans/{planID}` +* **Method** `GET` +* **Response** +```json +{ + "id":1, + "name":"Basic", + "stripeID":"stripe_123", + "organization":{ + "memberships":1, + "subOrgs":1 + }, + "votingTypes":{ + "approval":true, + "ranked":true, + "weighted":true + }, + "features":{ + "personalization":false, + "emailReminder":true, + "smsNotification":false + } +} +``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `400` | `40010` | `malformed URL parameter` | +| `400` | `40023` | `plan not found` | +| `500` | `50002` | `internal server error` | diff --git a/api/errors_definition.go b/api/errors_definition.go index b87cc4f..904a229 100644 --- a/api/errors_definition.go +++ b/api/errors_definition.go @@ -25,26 +25,30 @@ import ( // Do note that HTTPstatus 204 No Content implies the response body will be empty, // so the Code and Message will actually be discarded, never sent to the client var ( - ErrUnauthorized = Error{Code: 40001, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user not authorized")} - ErrEmailMalformed = Error{Code: 40002, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("email malformed")} - ErrPasswordTooShort = Error{Code: 40003, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("password too short")} - ErrMalformedBody = Error{Code: 40004, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed JSON body")} - ErrDuplicateConflict = Error{Code: 40901, HTTPstatus: http.StatusConflict, Err: fmt.Errorf("duplicate conflict")} - ErrInvalidUserData = Error{Code: 40005, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid user data")} - ErrCouldNotSignTransaction = Error{Code: 40006, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("could not sign transaction")} - ErrInvalidTxFormat = Error{Code: 40007, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid transaction format")} - ErrTxTypeNotAllowed = Error{Code: 40008, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("transaction type not allowed")} - ErrOrganizationNotFound = Error{Code: 40009, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization not found")} - ErrMalformedURLParam = Error{Code: 40010, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed URL parameter")} - ErrNoOrganizationProvided = Error{Code: 40011, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("no organization provided")} - ErrNoOrganizations = Error{Code: 40012, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("this user has not been assigned to any organization")} - ErrInvalidOrganizationData = Error{Code: 40013, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid organization data")} - ErrUserNoVerified = Error{Code: 40014, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user account not verified")} - ErrUserAlreadyVerified = Error{Code: 40015, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("user account already verified")} - ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")} - ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")} - ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")} - ErrInvitationExpired = Error{Code: 40019, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("inviation code expired")} + ErrUnauthorized = Error{Code: 40001, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user not authorized")} + ErrEmailMalformed = Error{Code: 40002, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("email malformed")} + ErrPasswordTooShort = Error{Code: 40003, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("password too short")} + ErrMalformedBody = Error{Code: 40004, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed JSON body")} + ErrDuplicateConflict = Error{Code: 40901, HTTPstatus: http.StatusConflict, Err: fmt.Errorf("duplicate conflict")} + ErrInvalidUserData = Error{Code: 40005, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid user data")} + ErrCouldNotSignTransaction = Error{Code: 40006, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("could not sign transaction")} + ErrInvalidTxFormat = Error{Code: 40007, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid transaction format")} + ErrTxTypeNotAllowed = Error{Code: 40008, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("transaction type not allowed")} + ErrOrganizationNotFound = Error{Code: 40009, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization not found")} + ErrMalformedURLParam = Error{Code: 40010, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed URL parameter")} + ErrNoOrganizationProvided = Error{Code: 40011, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("no organization provided")} + ErrNoOrganizations = Error{Code: 40012, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("this user has not been assigned to any organization")} + ErrInvalidOrganizationData = Error{Code: 40013, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid organization data")} + ErrUserNoVerified = Error{Code: 40014, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user account not verified")} + ErrUserAlreadyVerified = Error{Code: 40015, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("user account already verified")} + ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")} + ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")} + ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")} + ErrInvitationExpired = Error{Code: 40019, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("inviation code expired")} + ErrNoOrganizationSubscription = Error{Code: 40020, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization subscription not found")} + ErrOganizationSubscriptionIncative = Error{Code: 40021, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("organization subscription not active")} + ErrNoDefaultPLan = Error{Code: 40022, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("did not found default plan for organization")} + ErPlanNotFound = Error{Code: 40023, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("plan not found")} ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")} ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")} diff --git a/api/organizations.go b/api/organizations.go index aa6dec1..e346670 100644 --- a/api/organizations.go +++ b/api/organizations.go @@ -68,6 +68,18 @@ func (a *API) createOrganizationHandler(w http.ResponseWriter, r *http.Request) } parentOrg = orgInfo.Parent.Address } + // find default plan + defaultPlan, err := a.db.DefaultPlan() + if err != nil || defaultPlan == nil { + ErrNoDefaultPLan.WithErr((err)).Write(w) + return + } + subscription := &db.OrganizationSubscription{ + PlanID: defaultPlan.ID, + StartDate: time.Now(), + Active: true, + MaxCensusSize: defaultPlan.Organization.CensusSize, + } // create the organization if err := a.db.SetOrganization(&db.Organization{ Address: signer.AddressString(), @@ -83,6 +95,7 @@ func (a *API) createOrganizationHandler(w http.ResponseWriter, r *http.Request) TokensPurchased: 0, TokensRemaining: 0, Parent: parentOrg, + Subscription: *subscription, }); err != nil { if err == db.ErrAlreadyExists { ErrInvalidOrganizationData.WithErr(err).Write(w) @@ -452,3 +465,45 @@ func (a *API) organizationsTypesHandler(w http.ResponseWriter, _ *http.Request) } httpWriteJSON(w, &OrganizationTypeList{Types: organizationTypes}) } + +// getOrganizationSubscriptionHandler handles the request to get the subscription of an organization. +// It returns the subscription with its information. +func (a *API) getOrganizationSubscriptionHandler(w http.ResponseWriter, r *http.Request) { + // get the user from the request context + user, ok := userFromContext(r.Context()) + if !ok { + ErrUnauthorized.Write(w) + return + } + // get the organization info from the request context + org, _, ok := a.organizationFromRequest(r) + if !ok { + ErrNoOrganizationProvided.Write(w) + return + } + if !user.HasRoleFor(org.Address, db.AdminRole) { + ErrUnauthorized.Withf("user is not admin of organization").Write(w) + return + } + if org.Subscription == (db.OrganizationSubscription{}) { + ErrNoOrganizationSubscription.Write(w) + return + } + if !org.Subscription.Active || + (org.Subscription.EndDate.After(time.Now()) && org.Subscription.StartDate.Before(time.Now())) { + ErrOganizationSubscriptionIncative.Write(w) + return + } + // get the subscription from the database + plan, err := a.db.Plan(org.Subscription.PlanID) + if err != nil { + ErrGenericInternalServerError.Withf("could not get subscription: %v", err).Write(w) + return + } + info := &OrganizationSubscriptionInfo{ + SubcriptionDetails: &org.Subscription, + Usage: &org.Counters, + Plan: plan, + } + httpWriteJSON(w, info) +} diff --git a/api/plans.go b/api/plans.go new file mode 100644 index 0000000..6b5c193 --- /dev/null +++ b/api/plans.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + "strconv" +) + +// getSubscriptionsHandler handles the request to get the subscriptions of an organization. +// It returns the list of subscriptions with their information. +func (a *API) getPlansHandler(w http.ResponseWriter, r *http.Request) { + // get the subscritions from the database + plans, err := a.db.Plans() + if err != nil { + ErrGenericInternalServerError.Withf("could not get plans: %v", err).Write(w) + return + } + // send the plans back to the user + httpWriteJSON(w, plans) +} + +func (a *API) planInfoHandler(w http.ResponseWriter, r *http.Request) { + // get the plan ID from the URL + planID := r.URL.Query().Get("planID") + // check the the planID is not empty + if planID == "" { + ErrMalformedURLParam.Withf("planID is required").Write(w) + return + } + // get the plan from the database + planIDUint, err := strconv.ParseUint(planID, 10, 64) + if err != nil { + ErrMalformedURLParam.Withf("invalid planID: %v", err).Write(w) + return + } + plan, err := a.db.Plan(planIDUint) + if err != nil { + ErPlanNotFound.Withf("could not get plan: %v", err).Write(w) + return + } + // send the plan back to the user + httpWriteJSON(w, plan) +} diff --git a/api/routes.go b/api/routes.go index df412e7..05ead71 100644 --- a/api/routes.go +++ b/api/routes.go @@ -56,4 +56,14 @@ const ( organizationRolesEndpoint = "/organizations/roles" // GET /organizations/types to get the available organization types organizationTypesEndpoint = "/organizations/types" + // GET /organizations/{address}/subscription to get the organization subscription + organizationSubscriptionEndpoint = "/organizations/{address}/subscription" + + // subscription routes + // GET /subscriptions to get the subscriptions of an organization + plansEndpoint = "/plans" + // GET /subscriptions/{planID} to get the plan information + planInfoEndpoint = "/plans/{planID}" + // POST /subscriptions/webhook to receive the subscription webhook from stripe + subscriptionsWebhook = "/subscriptions/webhook" ) diff --git a/api/stripe.go b/api/stripe.go new file mode 100644 index 0000000..c7bfaa8 --- /dev/null +++ b/api/stripe.go @@ -0,0 +1,88 @@ +package api + +import ( + "io" + "net/http" + "time" + + "github.com/vocdoni/saas-backend/db" + "go.vocdoni.io/dvote/log" +) + +// handleWebhook handles the incoming webhook event from Stripe. +// It takes the API data and signature as input parameters and returns the session ID and an error (if any). +// The request body and Stripe-Signature header are passed to ConstructEvent, along with the webhook signing key. +// If the event type is "customer.subscription.created", it unmarshals the event data into a CheckoutSession struct +// and returns the session ID. Otherwise, it returns an empty string. +func (a *API) handleWebhook(w http.ResponseWriter, r *http.Request) { + const MaxBodyBytes = int64(65536) + r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes) + payload, err := io.ReadAll(r.Body) + if err != nil { + + log.Errorf("stripe webhook: Error reading request body: %s\n", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + signatureHeader := r.Header.Get("Stripe-Signature") + event, err := a.stripe.DecodeEvent(payload, signatureHeader) + if err != nil { + log.Errorf("stripe webhook: error decoding event: %s\n", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + // Unmarshal the event data into an appropriate struct depending on its Type + switch event.Type { + case "customer.subscription.created": + customer, subscription, err := a.stripe.GetInfoFromEvent(*event) + if err != nil { + log.Errorf("stripe webhook: error getting info from event: %s\n", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + address := subscription.Metadata["address"] + if len(address) == 0 { + log.Errorf("subscription %s does not contain an address in metadata", subscription.ID) + w.WriteHeader(http.StatusBadRequest) + return + } + org, _, err := a.db.Organization(address, false) + if err != nil || org == nil { + log.Errorf("could not update subscription %s, a corresponding organization with address %s was not found.", + subscription.ID, address) + log.Errorf("please do manually for creator %s \n Error: %s", customer.Email, err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + dbSubscription, err := a.db.PlanByStripeId(subscription.Items.Data[0].Plan.Product.ID) + if err != nil || dbSubscription == nil { + log.Errorf("could not update subscription %s, a corresponding subscription was not found.", + subscription.ID) + log.Errorf("please do manually: %s", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + startDate := time.Unix(subscription.CurrentPeriodStart, 0) + endDate := time.Unix(subscription.CurrentPeriodEnd, 0) + renewalDate := time.Unix(subscription.BillingCycleAnchor, 0) + + organizationSubscription := &db.OrganizationSubscription{ + PlanID: dbSubscription.ID, + StartDate: startDate, + EndDate: endDate, + RenewalDate: renewalDate, + Active: subscription.Status == "active", + MaxCensusSize: int(subscription.Items.Data[0].Quantity), + } + + // TODO will only worked for new subscriptions + if err := a.db.SetOrganizationSubscription(org.Address, organizationSubscription); err != nil { + log.Errorf("could not update subscription %s for organization %s: %s", subscription.ID, org.Address, err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + log.Debugf("stripe webhook: subscription %s for organization %s processed successfully", subscription.ID, org.Address) + } + w.WriteHeader(http.StatusOK) +} diff --git a/api/transaction.go b/api/transaction.go index 669f044..c5529d7 100644 --- a/api/transaction.go +++ b/api/transaction.go @@ -69,6 +69,7 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { } // check if the api is not in transparent mode if !a.transparentMode { + // get subscription plan switch tx.Payload.(type) { case *models.Tx_SetAccount: txSetAccount := tx.GetSetAccount() @@ -110,13 +111,19 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { case *models.Tx_NewProcess: txNewProcess := tx.GetNewProcess() // check the tx fields - if txNewProcess == nil || txNewProcess.Process == nil || txNewProcess.Nonce == 0 { + if txNewProcess == nil || txNewProcess.Process == nil { + // if txNewProcess == nil || txNewProcess.Process == nil || txNewProcess.Nonce == 0 { ErrInvalidTxFormat.With("missing fields").Write(w) return } + if hasPermission, err := a.subscriptions.HasPermission(tx, txNewProcess.Txtype, org); !hasPermission || err != nil { + ErrUnauthorized.Withf("user does not have permission to sign transactions: %v", err).Write(w) + return + } // check the tx subtype switch txNewProcess.Txtype { case models.TxType_NEW_PROCESS: + // generate a new faucet package if it's not present and include it in the tx if txNewProcess.FaucetPackage == nil { // get the tx cost for the tx type @@ -162,6 +169,10 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrInvalidTxFormat.With("invalid tx type").Write(w) return } + if hasPermission, err := a.subscriptions.HasPermission(tx, txSetProcess.Txtype, org); !hasPermission || err != nil { + ErrUnauthorized.Withf("user does not have permission to sign transactions: %v", err).Write(w) + return + } // check the tx subtype switch txSetProcess.Txtype { case models.TxType_SET_PROCESS_STATUS: diff --git a/api/types.go b/api/types.go index 61d275a..3804bb1 100644 --- a/api/types.go +++ b/api/types.go @@ -9,17 +9,19 @@ import ( // Organization is the struct that represents an organization in the API type OrganizationInfo struct { - Address string `json:"address"` - Website string `json:"website"` - CreatedAt string `json:"createdAt"` - Type string `json:"type"` - Size string `json:"size"` - Color string `json:"color"` - Subdomain string `json:"subdomain"` - Country string `json:"country"` - Timezone string `json:"timezone"` - Active bool `json:"active"` - Parent *OrganizationInfo `json:"parent"` + Address string `json:"address"` + Website string `json:"website"` + CreatedAt string `json:"createdAt"` + Type string `json:"type"` + Size string `json:"size"` + Color string `json:"color"` + Subdomain string `json:"subdomain"` + Country string `json:"country"` + Timezone string `json:"timezone"` + Active bool `json:"active"` + Parent *OrganizationInfo `json:"parent"` + Subscription *db.OrganizationSubscription `json:"subscription"` + Counters *db.OrganizationCounters `json:"counters"` } // OrganizationMembers is the struct that represents a list of members of @@ -158,16 +160,26 @@ func organizationFromDB(dbOrg, parent *db.Organization) *OrganizationInfo { parentOrg = organizationFromDB(parent, nil) } return &OrganizationInfo{ - Address: dbOrg.Address, - Website: dbOrg.Website, - CreatedAt: dbOrg.CreatedAt.Format(time.RFC3339), - Type: string(dbOrg.Type), - Size: dbOrg.Size, - Color: dbOrg.Color, - Subdomain: dbOrg.Subdomain, - Country: dbOrg.Country, - Timezone: dbOrg.Timezone, - Active: dbOrg.Active, - Parent: parentOrg, + Address: dbOrg.Address, + Website: dbOrg.Website, + CreatedAt: dbOrg.CreatedAt.Format(time.RFC3339), + Type: string(dbOrg.Type), + Size: dbOrg.Size, + Color: dbOrg.Color, + Subdomain: dbOrg.Subdomain, + Country: dbOrg.Country, + Timezone: dbOrg.Timezone, + Active: dbOrg.Active, + Parent: parentOrg, + Subscription: &dbOrg.Subscription, + Counters: &dbOrg.Counters, } } + +// OrganizationSubscriptionInfo is the struct used to provide detailed information +// regaridng the subscription of an organization. +type OrganizationSubscriptionInfo struct { + SubcriptionDetails *db.OrganizationSubscription `json:"subscriptionDetails"` + Usage *db.OrganizationCounters `json:"usage"` + Plan *db.Plan `json:"plan"` +} diff --git a/assets/subscriptions.json b/assets/subscriptions.json new file mode 100644 index 0000000..da9c3e7 --- /dev/null +++ b/assets/subscriptions.json @@ -0,0 +1,63 @@ +[ + { + "ID": 1, + "Name": "Basic", + "StripeID": "prod_R3LTVsjklmuQAL", + "Default": false, + "Organization": { + "Memberships": 5, + "SubOrgs": 1 + }, + "VotingTypes": { + "Approval": true, + "Ranked": true, + "Weighted": true + }, + "Features": { + "Personalization": false, + "EmailReminder": false, + "SmsNotification": false + } + }, + { + "ID": 2, + "Name": "Pro", + "StripeID": "prod_R0kTryoMNl8I19", + "Default": false, + "Organization": { + "Memberships": 10, + "SubOrgs": 5 + }, + "VotingTypes": { + "Approval": true, + "Ranked": true, + "Weighted": true + }, + "Features": { + "Personalization": true, + "EmailReminder": true, + "SmsNotification": true + } + }, + { + "ID": 3, + "Name": "free", + "Default": true, + "StripeID": "stripe_789", + "Organization": { + "Memberships": 10, + "SubOrgs": 5, + "CensusSize": 10 + }, + "VotingTypes": { + "Approval": false, + "Ranked": false, + "Weighted": true + }, + "Features": { + "Personalization": false, + "EmailReminder": false, + "SmsNotification": false + } + } +] \ No newline at end of file diff --git a/cmd/service/main.go b/cmd/service/main.go index f45573d..82b6acd 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -12,12 +12,13 @@ import ( "github.com/vocdoni/saas-backend/api" "github.com/vocdoni/saas-backend/db" "github.com/vocdoni/saas-backend/notifications/smtp" + "github.com/vocdoni/saas-backend/stripe" + "github.com/vocdoni/saas-backend/subscriptions" "go.vocdoni.io/dvote/apiclient" "go.vocdoni.io/dvote/log" ) func main() { - log.Init("debug", "stdout", nil) // define flags flag.StringP("host", "h", "0.0.0.0", "listen address") flag.IntP("port", "p", 8080, "listen port") @@ -27,12 +28,15 @@ func main() { flag.StringP("vocdoniApi", "v", "https://api-dev.vocdoni.net/v2", "vocdoni node remote API URL") 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("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") flag.String("smtpPassword", "", "SMTP password") flag.String("emailFromAddress", "", "Email service from address") flag.String("emailFromName", "Vocdoni", "Email service from name") + flag.String("stripeApiSecret", "", "Stripe API secret") + flag.String("stripeWebhookSecret", "", "Stripe Webhook secret") // parse flags flag.Parse() // initialize Viper @@ -51,6 +55,7 @@ func main() { } mongoURL := viper.GetString("mongoURL") mongoDB := viper.GetString("mongoDB") + plansFile := viper.GetString("plansFile") // email vars smtpServer := viper.GetString("smtpServer") smtpPort := viper.GetInt("smtpPort") @@ -58,8 +63,13 @@ func main() { smtpPassword := viper.GetString("smtpPassword") emailFromAddress := viper.GetString("emailFromAddress") emailFromName := viper.GetString("emailFromName") + // stripe vars + stripeApiSecret := viper.GetString("stripeApiSecret") + stripeWebhookSecret := viper.GetString("stripeWebhookSecret") + + log.Init("debug", "stdout", os.Stderr) // initialize the MongoDB database - database, err := db.New(mongoURL, mongoDB) + database, err := db.New(mongoURL, mongoDB, plansFile) if err != nil { log.Fatalf("could not create the MongoDB database: %v", err) } @@ -110,6 +120,16 @@ 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, + }) + apiConf.Subscriptions = subscriptions // create the local API server api.New(apiConf).Start() log.Infow("server started", "host", host, "port", port) diff --git a/db/helpers.go b/db/helpers.go index 82e5ef2..6cde10d 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -2,10 +2,12 @@ package db import ( "context" + "encoding/json" "fmt" "reflect" "time" + root "github.com/vocdoni/saas-backend" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" @@ -23,6 +25,12 @@ func (ms *MongoStorage) initCollections(database string) error { if err != nil { 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 @@ -43,6 +51,12 @@ func (ms *MongoStorage) initCollections(database string) error { return nil, fmt.Errorf("failed to update collection validator: %w", err) } } + if name == "plans" { + // clear subscriptions collection and update the DB with the new ones + if _, err := ms.client.Database(database).Collection(name).DeleteMany(ctx, bson.D{}); err != nil { + return nil, err + } + } } else { // if the collection has a validator create it with it opts := options.CreateCollection() @@ -54,6 +68,16 @@ func (ms *MongoStorage) initCollections(database string) error { return nil, err } } + if name == "plans" { + var plans []interface{} + for _, plan := range loadedPlans { + plans = append(plans, plan) + } + count, err := ms.client.Database(database).Collection(name).InsertMany(ctx, plans) + if err != nil || len(count.InsertedIDs) != len(loadedPlans) { + return nil, fmt.Errorf("failed to insert plans: %w", err) + } + } // return the collection return ms.client.Database(database).Collection(name), nil } @@ -73,6 +97,10 @@ func (ms *MongoStorage) initCollections(database string) error { if ms.organizationInvites, err = getCollection("organizationInvites"); err != nil { return err } + // subscriptions collection + if ms.plans, err = getCollection("plans"); err != nil { + return err + } return nil } @@ -193,3 +221,36 @@ func dynamicUpdateDocument(item interface{}, alwaysUpdateTags []string) (bson.M, } return bson.M{"$set": update}, nil } + +// 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)) + 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) + + var plans []*Plan + err = decoder.Decode(&plans) + if err != nil { + return nil, err + } + // print plans + for _, sub := range plans { + fmt.Println(sub) + } + return plans, nil +} diff --git a/db/mongo.go b/db/mongo.go index 22aba88..039fa99 100644 --- a/db/mongo.go +++ b/db/mongo.go @@ -17,14 +17,16 @@ 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 + database string + client *mongo.Client + keysLock sync.RWMutex + plansFile string users *mongo.Collection verifications *mongo.Collection organizations *mongo.Collection organizationInvites *mongo.Collection + plans *mongo.Collection } type Options struct { @@ -32,7 +34,7 @@ type Options struct { Database string } -func New(url, database string) (*MongoStorage, error) { +func New(url, database, plansFile string) (*MongoStorage, error) { var err error ms := &MongoStorage{} if url == "" { @@ -65,6 +67,7 @@ func New(url, database string) (*MongoStorage, error) { // init the database client ms.client = client ms.database = database + ms.plansFile = plansFile // init the collections if err := ms.initCollections(ms.database); err != nil { return nil, err @@ -112,6 +115,10 @@ func (ms *MongoStorage) Reset() error { if err := ms.verifications.Drop(ctx); err != nil { return err } + // drop subscriptions collection + if err := ms.plans.Drop(ctx); err != nil { + return err + } // init the collections if err := ms.initCollections(ms.database); err != nil { return err diff --git a/db/mongo_test.go b/db/mongo_test.go index 68d1610..93864f8 100644 --- a/db/mongo_test.go +++ b/db/mongo_test.go @@ -27,7 +27,7 @@ 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()) + db, err = New(mongoURI, test.RandomDatabaseName(), "subscriptions.json") if err != nil { panic(err) } diff --git a/db/mongo_types.go b/db/mongo_types.go index e6ebde4..fd8be57 100644 --- a/db/mongo_types.go +++ b/db/mongo_types.go @@ -12,6 +12,10 @@ type OrganizationCollection struct { Organizations []Organization `json:"organizations" bson:"organizations"` } +type PlanCollection struct { + Plans []Plan `json:"plans" bson:"plans"` +} + type OrganizationInvitesCollection struct { OrganizationInvites []OrganizationInvite `json:"organizationInvites" bson:"organizationInvites"` } diff --git a/db/organizations.go b/db/organizations.go index 9efe02d..3b03760 100644 --- a/db/organizations.go +++ b/db/organizations.go @@ -16,7 +16,7 @@ func (ms *MongoStorage) organization(ctx context.Context, address string) (*Orga // find the organization in the database by its address (case insensitive) filter := bson.M{"_id": bson.M{"$regex": address, "$options": "i"}} result := ms.organizations.FindOne(ctx, filter) - org := &Organization{} + org := &Organization{Subscription: OrganizationSubscription{}} if err := result.Decode(org); err != nil { // if the organization doesn't exist return a specific error if err == mongo.ErrNoDocuments { @@ -155,3 +155,24 @@ func (ms *MongoStorage) OrganizationsMembers(address string) ([]User, error) { } return users, nil } + +// SetOrganizationSubscription method adds the provided subscription to +// the organization with the given address +func (ms *MongoStorage) SetOrganizationSubscription(address string, orgSubscription *OrganizationSubscription) error { + if _, err := ms.Plan(orgSubscription.PlanID); err != nil { + return ErrInvalidData + } + ms.keysLock.Lock() + defer ms.keysLock.Unlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // prepare the document to be updated in the database + filter := bson.M{"_id": address} + updateDoc := bson.M{"$set": bson.M{"subscription": orgSubscription}} + // update the organization in the database + if _, err := ms.organizations.UpdateOne(ctx, filter, updateDoc); err != nil { + return err + } + return nil +} diff --git a/db/organizations_test.go b/db/organizations_test.go index 4bf070c..29d7a11 100644 --- a/db/organizations_test.go +++ b/db/organizations_test.go @@ -2,6 +2,7 @@ package db import ( "testing" + "time" qt "github.com/frankban/quicktest" ) @@ -22,8 +23,9 @@ func TestOrganization(t *testing.T) { // create a new organization with the address and a not found parent parentAddress := "parentOrgToGet" c.Assert(db.SetOrganization(&Organization{ - Address: address, - Parent: parentAddress, + Address: address, + Parent: parentAddress, + Subscription: OrganizationSubscription{}, }), qt.IsNil) // test not found parent organization _, parentOrg, err := db.Organization(address, true) @@ -178,3 +180,45 @@ func TestOrganizationsMembers(t *testing.T) { singleMember := members[0] c.Assert(singleMember.Email, qt.Equals, testUserEmail) } + +func TestAddOrganizationPlan(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) + // create a new organization + address := "orgToAddPlan" + c.Assert(db.SetOrganization(&Organization{ + Address: address, + }), qt.IsNil) + // add a subscription to the organization + subscriptionName := "testPlan" + startDate := time.Now() + endDate := startDate.AddDate(1, 0, 0) + active := true + stripeID := "stripeID" + orgSubscription := &OrganizationSubscription{ + StartDate: startDate, + EndDate: endDate, + Active: true, + } + // using a non existing subscription should fail + c.Assert(db.SetOrganizationSubscription(address, orgSubscription), qt.IsNotNil) + subscriptionID, err := db.SetPlan(&Plan{ + Name: subscriptionName, + StripeID: stripeID, + }) + if err != nil { + t.Error(err) + } + orgSubscription.PlanID = subscriptionID + c.Assert(db.SetOrganizationSubscription(address, orgSubscription), qt.IsNil) + // retrieve the organization and check the subscription details + org, _, err := db.Organization(address, false) + c.Assert(err, qt.IsNil) + c.Assert(org, qt.Not(qt.IsNil)) + c.Assert(org.Address, qt.Equals, address) + c.Assert(org.Subscription.Active, qt.Equals, active) +} diff --git a/db/plans.go b/db/plans.go new file mode 100644 index 0000000..79e400d --- /dev/null +++ b/db/plans.go @@ -0,0 +1,170 @@ +package db + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.vocdoni.io/dvote/log" +) + +// nextPlanID internal method returns the next available subsbscription ID. If an error +// occurs, it returns the error. This method must be called with the keysLock +// held. +func (ms *MongoStorage) nextPlanID(ctx context.Context) (uint64, error) { + var plan Plan + opts := options.FindOne().SetSort(bson.D{{Key: "_id", Value: -1}}) + if err := ms.plans.FindOne(ctx, bson.M{}, opts).Decode(&plan); err != nil { + if err == mongo.ErrNoDocuments { + return 1, nil + } else { + return 0, err + } + } + return plan.ID + 1, nil +} + +// SetPlan method creates or updates the plan in the database. +// If the plan already exists, it updates the fields that have changed. +func (ms *MongoStorage) SetPlan(plan *Plan) (uint64, error) { + ms.keysLock.Lock() + defer ms.keysLock.Unlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + nextID, err := ms.nextPlanID(ctx) + if err != nil { + return 0, err + } + if plan.ID > 0 { + if plan.ID >= nextID { + return 0, ErrInvalidData + } + updateDoc, err := dynamicUpdateDocument(plan, nil) + if err != nil { + return 0, err + } + // set upsert to true to create the document if it doesn't exist + if _, err := ms.plans.UpdateOne(ctx, bson.M{"_id": plan.ID}, updateDoc); err != nil { + return 0, err + } + } else { + plan.ID = nextID + if _, err := ms.plans.InsertOne(ctx, plan); err != nil { + return 0, err + } + } + return plan.ID, nil +} + +// Plan method returns the plan with the given ID. If the +// plan doesn't exist, it returns the specific error. +func (ms *MongoStorage) Plan(planID uint64) (*Plan, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // find the plan in the database + filter := bson.M{"_id": planID} + plan := &Plan{} + err := ms.plans.FindOne(ctx, filter).Decode(plan) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, ErrNotFound // Plan not found + } + return nil, errors.New("failed to get plan") + } + return plan, nil +} + +// PlanByStripeId method returns the plan with the given stripe ID. If the +// plan doesn't exist, it returns the specific error. +func (ms *MongoStorage) PlanByStripeId(stripeID string) (*Plan, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // find the plan in the database + filter := bson.M{"stripeID": stripeID} + plan := &Plan{} + err := ms.plans.FindOne(ctx, filter).Decode(plan) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, ErrNotFound // Plan not found + } + return nil, errors.New("failed to get plan") + } + return plan, nil +} + +// DefaultPlan method returns the default plan plan. If the +// plan doesn't exist, it returns the specific error. +func (ms *MongoStorage) DefaultPlan() (*Plan, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // find the plan in the database + filter := bson.M{"default": true} + plan := &Plan{} + err := ms.plans.FindOne(ctx, filter).Decode(plan) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, ErrNotFound // Plan not found + } + return nil, errors.New("failed to get plan") + } + return plan, nil +} + +// Plans method returns all plans from the database. +func (ms *MongoStorage) Plans() ([]*Plan, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // find all plans in the database + cursor, err := ms.plans.Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer func() { + if err := cursor.Close(ctx); err != nil { + log.Warnw("failed to close plans file", "error", err) + } + }() + + // iterate over the cursor and decode each plan + var plans []*Plan + for cursor.Next(ctx) { + plan := &Plan{} + if err := cursor.Decode(plan); err != nil { + return nil, err + } + plans = append(plans, plan) + } + if err := cursor.Err(); err != nil { + return nil, err + } + return plans, nil +} + +// DelPlan method deletes the plan with the given ID. If the +// plan doesn't exist, it returns the specific error. +func (ms *MongoStorage) DelPlan(plan *Plan) error { + ms.keysLock.Lock() + defer ms.keysLock.Unlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // delete the organization from the database + _, err := ms.plans.DeleteOne(ctx, bson.M{"_id": plan.ID}) + return err +} diff --git a/db/plans_test.go b/db/plans_test.go new file mode 100644 index 0000000..b1a05ec --- /dev/null +++ b/db/plans_test.go @@ -0,0 +1,72 @@ +package db + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestSetPlan(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) + + plan := &Plan{ + Name: "Test Plan", + StripeID: "stripeID", + } + _, err := db.SetPlan(plan) + c.Assert(err, qt.IsNil) +} + +func TestPlan(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) // Create a new quicktest instance + planID := uint64(123) + // Test not found plan + plan, err := db.Plan(planID) + c.Assert(err, qt.Equals, ErrNotFound) + c.Assert(plan, qt.IsNil) + plan = &Plan{ + Name: "Test Plan", + StripeID: "stripeID", + } + planID, err = db.SetPlan(plan) + if err != nil { + t.Error(err) + } + // Test found plan + planDB, err := db.Plan(planID) + c.Assert(err, qt.IsNil) + c.Assert(planDB, qt.Not(qt.IsNil)) + c.Assert(planDB.ID, qt.Equals, plan.ID) +} + +func TestDelPlan(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) + // Create a new plan and delete it + plan := &Plan{ + Name: "Test Plan", + StripeID: "stripeID", + } + id, err := db.SetPlan(plan) + c.Assert(err, qt.IsNil) + err = db.DelPlan(plan) + c.Assert(err, qt.IsNil) + + // Test not found plan + _, err = db.Plan(id) + c.Assert(err, qt.Equals, ErrNotFound) +} diff --git a/db/types.go b/db/types.go index 39ec2ea..da6d72d 100644 --- a/db/types.go +++ b/db/types.go @@ -1,6 +1,8 @@ package db -import "time" +import ( + "time" +) type User struct { ID uint64 `json:"id" bson:"_id"` @@ -40,21 +42,67 @@ type OrganizationMember struct { } type Organization struct { - Address string `json:"address" bson:"_id"` - Website string `json:"website" bson:"website"` - Type OrganizationType `json:"type" bson:"type"` - Creator string `json:"creator" bson:"creator"` - CreatedAt time.Time `json:"createdAt" bson:"createdAt"` - Nonce string `json:"nonce" bson:"nonce"` - Size string `json:"size" bson:"size"` - Color string `json:"color" bson:"color"` - Subdomain string `json:"subdomain" bson:"subdomain"` - Country string `json:"country" bson:"country"` - Timezone string `json:"timezone" bson:"timezone"` - Active bool `json:"active" bson:"active"` - TokensPurchased uint64 `json:"tokensPurchased" bson:"tokensPurchased"` - TokensRemaining uint64 `json:"tokensRemaining" bson:"tokensRemaining"` - Parent string `json:"parent" bson:"parent"` + Address string `json:"address" bson:"_id"` + Website string `json:"website" bson:"website"` + Type OrganizationType `json:"type" bson:"type"` + Creator string `json:"creator" bson:"creator"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + Nonce string `json:"nonce" bson:"nonce"` + Size string `json:"size" bson:"size"` + Color string `json:"color" bson:"color"` + Subdomain string `json:"subdomain" bson:"subdomain"` + Country string `json:"country" bson:"country"` + Timezone string `json:"timezone" bson:"timezone"` + Active bool `json:"active" bson:"active"` + TokensPurchased uint64 `json:"tokensPurchased" bson:"tokensPurchased"` + TokensRemaining uint64 `json:"tokensRemaining" bson:"tokensRemaining"` + Parent string `json:"parent" bson:"parent"` + Subscription OrganizationSubscription `json:"subscription" bson:"subscription"` + Counters OrganizationCounters `json:"counters" bson:"counters"` +} + +type PlanLimits struct { + Memberships int `json:"memberships" bson:"memberships"` + SubOrgs int `json:"subOrgs" bson:"subOrgs"` + CensusSize int `json:"censusSize" bson:"censusSize"` +} + +type VotingTypes struct { + Approval bool `json:"approval" bson:"approval"` + Ranked bool `json:"ranked" bson:"ranked"` + Weighted bool `json:"weighted" bson:"weighted"` +} + +type Features struct { + Personalization bool `json:"personalization" bson:"personalization"` + EmailReminder bool `json:"emailReminder" bson:"emailReminder"` + SmsNotification bool `json:"smsNotification" bson:"smsNotification"` +} + +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"` +} + +type OrganizationSubscription struct { + PlanID uint64 `json:"planID" bson:"planID"` + StartDate time.Time `json:"startDate" bson:"startDate"` + EndDate time.Time `json:"endDate" bson:"endDate"` + RenewalDate time.Time `json:"renewalDate" bson:"renewalDate"` + Active bool `json:"active" bson:"active"` + MaxCensusSize int `json:"maxCensusSize" bson:"maxCensusSize"` +} + +type OrganizationCounters struct { + SentSMS int `json:"sentSMS" bson:"sentSMS"` + SentEmails int `json:"sentEmails" bson:"sentEmails"` + SubOrgs int `json:"subOrgs" bson:"subOrgs"` + Members int `json:"members" bson:"members"` } type OrganizationInvite struct { diff --git a/db/validations.go b/db/validations.go index b670c97..a669b56 100644 --- a/db/validations.go +++ b/db/validations.go @@ -7,6 +7,7 @@ import ( var collectionsValidators = map[string]bson.M{ "users": usersCollectionValidator, + "subscriptions": subscriptionCollectionValidator, "organizationInvites": organizationInvitesCollectionValidator, } @@ -67,3 +68,85 @@ var organizationInvitesCollectionValidator = bson.M{ }, }, } + +var subscriptionCollectionValidator = bson.M{ + "$jsonSchema": bson.M{ + "bsonType": "object", + "required": []string{"_id", "name", "stripeID"}, + "properties": bson.M{ + "id": bson.M{ + "bsonType": "int", + "description": "must be an integer and is required", + "minimum": 1, + }, + "name": bson.M{ + "bsonType": "string", + "description": "the name of the subscription plan must be a string and is required", + }, + "stripeID": bson.M{ + "bsonType": "string", + "description": "the corresponding plan ID must be a string and is required", + }, + // "organization": bson.M{ + // "bsonType": "object", + // "description": "the organization limits must be an object and is required", + // // "required": []string{"memberships", "subOrgs", "maxCensusSize"}, + // "properties": bson.M{ + // "memberships": bson.M{ + // "bsonType": "int", + // "description": "the max number of memberships allowed must be an integer and is required", + // "minimum": 1, + // }, + // "subOrgs": bson.M{ + // "bsonType": "int", + // "description": "the max number of sub organizations allowed must be an integer and is required", + // "minimum": 1, + // }, + // "maxCensusSize": bson.M{ + // "bsonType": "int", + // "description": "the max number of participants allowed in the each election must be an integer and is required", + // "minimum": 1, + // }, + // }, + // }, + // "votingTypes": bson.M{ + // "bsonType": "object", + // "description": "the voting types allowed must be an object and is required", + // // "required": []string{"approval", "ranked", "weighted"}, + // "properties": bson.M{ + // "approval": bson.M{ + // "bsonType": "bool", + // "description": "approval voting must be a boolean and is required", + // }, + // "ranked": bson.M{ + // "bsonType": "bool", + // "description": "ranked voting must be a boolean and is required", + // }, + // "weighted": bson.M{ + // "bsonType": "bool", + // "description": "weighted voting must be a boolean and is required", + // }, + // }, + // }, + // "features": bson.M{ + // "bsonType": "object", + // "description": "the features enabled must be an object and is required", + // // "required": []string{"personalization", "emailReminder", "smsNotification"}, + // "properties": bson.M{ + // "personalization": bson.M{ + // "bsonType": "bool", + // "description": "personalization must be a boolean and is required", + // }, + // "emailReminder": bson.M{ + // "bsonType": "bool", + // "description": "emailReminder must be a boolean and is required", + // }, + // "smsNotification": bson.M{ + // "bsonType": "bool", + // "description": "smsNotification must be a boolean and is required", + // }, + // }, + // }, + }, + }, +} diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..f644d19 --- /dev/null +++ b/embed.go @@ -0,0 +1,6 @@ +package root + +import "embed" + +//go:embed all:assets +var Assets embed.FS diff --git a/example.env b/example.env index 0cf14f3..2c9e1e1 100644 --- a/example.env +++ b/example.env @@ -5,4 +5,6 @@ VOCDONI_SMTPSERVER=smpt.server.com VOCDONI_SMTPUSERNAME=admin VOCDONI_SMTPPASSWORD=password VOCDONI_EMAILFROMADDRESS=admin@email.com -VOCDONI_EMAILFROMADDRESS=admin@email.com \ No newline at end of file +VOCDONI_EMAILFROMADDRESS=admin@email.com +STRIPE_API_SECRET=stripe_key +STRIPE_WEBHOOK_SECRET=stripe_webhook_key diff --git a/go.mod b/go.mod index 0638a53..9981691 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/lestrrat-go/jwx/v2 v2.0.20 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 + github.com/stripe/stripe-go/v81 v81.0.0 github.com/testcontainers/testcontainers-go v0.32.0 github.com/twilio/twilio-go v1.23.0 go.mongodb.org/mongo-driver v1.14.0 diff --git a/go.sum b/go.sum index 9eda011..5933f81 100644 --- a/go.sum +++ b/go.sum @@ -1485,6 +1485,8 @@ github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stripe/stripe-go/v81 v81.0.0 h1:7xqKVXIjhFoSEUzXXPON7oYFRupOyhDG5R7tRVyrgeE= +github.com/stripe/stripe-go/v81 v81.0.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807/go.mod h1:7jxmlfBCDBXRzr0eAQJ48XC1hBu1np4CS5+cHEYfwpc= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= @@ -1793,6 +1795,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/stripe/stripe.go b/stripe/stripe.go new file mode 100644 index 0000000..9baa77e --- /dev/null +++ b/stripe/stripe.go @@ -0,0 +1,59 @@ +package stripe + +import ( + "encoding/json" + + "github.com/stripe/stripe-go/v81" + "github.com/stripe/stripe-go/v81/customer" + "github.com/stripe/stripe-go/v81/webhook" + "go.vocdoni.io/dvote/log" +) + +// StripeClient is a client for interacting with the Stripe API. +// It holds the necessary configuration such as the webhook secret. +type StripeClient struct { + webhookSecret string +} + +// New creates a new instance of StripeClient with the provided API secret and webhook secret. +// It sets the Stripe API key to the provided apiSecret. +func New(apiSecret, webhookSecret string) *StripeClient { + stripe.Key = apiSecret + return &StripeClient{ + webhookSecret: webhookSecret, + } +} + +// DecodeEvent decodes a Stripe webhook event from the given payload and signature header. +func (s *StripeClient) DecodeEvent(payload []byte, signatureHeader string) (*stripe.Event, error) { + event := stripe.Event{} + if err := json.Unmarshal(payload, &event); err != nil { + log.Errorf("stripe webhook: error while parsing basic request. %s\n", err.Error()) + return nil, err + } + + event, err := webhook.ConstructEvent(payload, signatureHeader, s.webhookSecret) + if err != nil { + log.Errorf("stripe webhook: webhook signature verification failed. %s\n", err.Error()) + return nil, err + } + return &event, nil +} + +// GetInfoFromEvent processes a Stripe event to extract customer and subscription information. +func (s *StripeClient) GetInfoFromEvent(event stripe.Event) (*stripe.Customer, *stripe.Subscription, error) { + var subscription stripe.Subscription + err := json.Unmarshal(event.Data.Raw, &subscription) + if err != nil { + log.Errorf("error parsing webhook JSON: %s\n", err.Error()) + return nil, nil, err + } + + params := &stripe.CustomerParams{} + customer, err := customer.Get(subscription.Customer.ID, params) + if err != nil || customer == nil { + log.Errorf("could not update subscription %s, stripe internal error getting customer", subscription.ID) + return nil, nil, err + } + return customer, &subscription, nil +} diff --git a/subscriptions/subscriptions.go b/subscriptions/subscriptions.go new file mode 100644 index 0000000..9efe6f5 --- /dev/null +++ b/subscriptions/subscriptions.go @@ -0,0 +1,51 @@ +package subscriptions + +import ( + "fmt" + + "github.com/vocdoni/saas-backend/db" + "go.vocdoni.io/proto/build/go/models" +) + +// SubscriptionsConfig holds the configuration for the subscriptions service. +// It includes a reference to the MongoDB storage used by the service. +type SubscriptionsConfig struct { + DB *db.MongoStorage +} + +// Subscriptions is the service that manages the organization permissions based on +// the subscription plans. +type Subscriptions struct { + db *db.MongoStorage +} + +// New creates a new Subscriptions service with the given configuration. +func New(conf *SubscriptionsConfig) *Subscriptions { + if conf == nil { + return nil + } + return &Subscriptions{ + db: conf.DB, + } +} + +// HasPermission checks if the organization has permission to perform the given transaction. +func (p *Subscriptions) HasPermission( + tx *models.Tx, + txType models.TxType, + org *db.Organization, +) (bool, error) { + // get subscription plan + // plan, err := p.db.Subscription(org.Subscription.SubscriptionID) + // if err != nil { + // return false, fmt.Errorf("could not get organization subscription: %v", err) + // } + switch txType { + case models.TxType_NEW_PROCESS, models.TxType_SET_PROCESS_CENSUS: + newProcess := tx.GetNewProcess() + if newProcess.Process.MaxCensusSize > uint64(org.Subscription.MaxCensusSize) { + return false, fmt.Errorf("MaxCensusSize is greater than the allowed") + } + } + return true, nil +}