diff --git a/api4/cloud.go b/api4/cloud.go index 3e38324b06..173d975279 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -22,6 +22,8 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/limits api.BaseRoutes.Cloud.Handle("/limits", api.APISessionRequired(getCloudLimits)).Methods("GET") + api.BaseRoutes.Cloud.Handle("/products/selfhosted", api.APISessionRequired(getSelfHostedProducts)).Methods("GET") + // POST /api/v4/cloud/payment // POST /api/v4/cloud/payment/confirm api.BaseRoutes.Cloud.Handle("/payment", api.APISessionRequired(createCustomerPayment)).Methods("POST") @@ -276,6 +278,40 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R } } +func getSelfHostedProducts(c *Context, w http.ResponseWriter, r *http.Request) { + products, err := c.App.Cloud().GetSelfHostedProducts(c.AppContext.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + byteProductsData, err := json.Marshal(products) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) { + sanitizedProducts := []model.UserFacingProduct{} + err = json.Unmarshal(byteProductsData, &sanitizedProducts) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + byteSanitizedProductsData, err := json.Marshal(sanitizedProducts) + if err != nil { + c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(byteSanitizedProductsData) + return + } + + w.Write(byteProductsData) +} + func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.Channels().License().IsCloud() { c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.license_error", nil, "", http.StatusForbidden) diff --git a/api4/cloud_test.go b/api4/cloud_test.go index 15ff7b1831..f44176656c 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -651,3 +651,110 @@ func TestGetCloudProducts(t *testing.T) { require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2") }) } + +func TestGetSelfHostedProducts(t *testing.T) { + products := []*model.Product{ + { + ID: "prod_test", + Name: "Self-Hosted Professional", + Description: "Ideal for small companies and departments with data security requirements", + PricePerSeat: 10, + SKU: "professional", + PriceID: "price_1JPXbNI67GP2qpb4VuFdFbwQ", + Family: "on-prem", + RecurringInterval: model.RecurringIntervalYearly, + }, + { + ID: "prod_test2", + Name: "Self-Hosted Enterprise", + Description: "Built to scale for high-trust organizations and companies in regulated industries.", + PricePerSeat: 30, + SKU: "enterprise", + PriceID: "price_1JPXaVI67GP2qpb4l40bXyRu", + Family: "on-prem", + RecurringInterval: model.RecurringIntervalYearly, + }, + } + + sanitizedProducts := []*model.Product{ + { + ID: "prod_test", + Name: "Self-Hosted Professional", + PricePerSeat: 10, + SKU: "professional", + RecurringInterval: model.RecurringIntervalYearly, + }, + { + ID: "prod_test2", + Name: "Self-Hosted Enterprise", + PricePerSeat: 30, + SKU: "enterprise", + RecurringInterval: model.RecurringIntervalYearly, + }, + } + + t.Run("get products for admins", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + cloud := mocks.CloudInterface{} + cloud.Mock.On("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + returnedProducts, r, err := th.Client.GetSelfHostedProducts() + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") + require.Equal(t, returnedProducts, products) + }) + + t.Run("get products for non admins", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + cloud := mocks.CloudInterface{} + + cloud.Mock.On("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + returnedProducts, r, err := th.Client.GetSelfHostedProducts() + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") + require.Equal(t, returnedProducts, sanitizedProducts) + + // make a more explicit check + require.Equal(t, returnedProducts[0].ID, "prod_test") + require.Equal(t, returnedProducts[0].Name, "Self-Hosted Professional") + require.Equal(t, returnedProducts[0].SKU, "professional") + require.Equal(t, returnedProducts[0].PricePerSeat, float64(10)) + require.Equal(t, returnedProducts[0].Description, "") + require.Equal(t, returnedProducts[0].PriceID, "") + require.Equal(t, returnedProducts[0].Family, model.SubscriptionFamily("")) + require.Equal(t, returnedProducts[0].RecurringInterval, model.RecurringInterval("year")) + require.Equal(t, returnedProducts[0].BillingScheme, model.BillingScheme("")) + require.Equal(t, returnedProducts[0].CrossSellsTo, "") + + require.Equal(t, returnedProducts[1].ID, "prod_test2") + require.Equal(t, returnedProducts[1].Name, "Self-Hosted Enterprise") + require.Equal(t, returnedProducts[1].SKU, "enterprise") + require.Equal(t, returnedProducts[1].PricePerSeat, float64(30)) + require.Equal(t, returnedProducts[1].Description, "") + require.Equal(t, returnedProducts[1].PriceID, "") + require.Equal(t, returnedProducts[1].Family, model.SubscriptionFamily("")) + require.Equal(t, returnedProducts[1].RecurringInterval, model.RecurringInterval("year")) + require.Equal(t, returnedProducts[1].BillingScheme, model.BillingScheme("")) + require.Equal(t, returnedProducts[1].CrossSellsTo, "") + }) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 8fa16ad023..46f5783225 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -10,6 +10,7 @@ import ( type CloudInterface interface { GetCloudProduct(userID string, productID string) (*model.Product, error) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) + GetSelfHostedProducts(userID string) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 05b8fe86d7..141823c834 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -279,6 +279,29 @@ func (_m *CloudInterface) GetLicenseRenewalStatus(userID string, token string) e return r0 } +// GetSelfHostedProducts provides a mock function with given fields: userID +func (_m *CloudInterface) GetSelfHostedProducts(userID string) ([]*model.Product, error) { + ret := _m.Called(userID) + + var r0 []*model.Product + if rf, ok := ret.Get(0).(func(string) []*model.Product); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Product) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetSubscription provides a mock function with given fields: userID func (_m *CloudInterface) GetSubscription(userID string) (*model.Subscription, error) { ret := _m.Called(userID) diff --git a/model/client4.go b/model/client4.go index 794969c0d6..fded08a23d 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8041,6 +8041,19 @@ func (c *Client4) GetCloudProducts() ([]*Product, *Response, error) { return cloudProducts, BuildResponse(r), nil } +func (c *Client4) GetSelfHostedProducts() ([]*Product, *Response, error) { + r, err := c.DoAPIGet(c.cloudRoute()+"/products/selfhosted", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var products []*Product + json.NewDecoder(r.Body).Decode(&products) + + return products, BuildResponse(r), nil +} + func (c *Client4) GetProductLimits() (*ProductLimits, *Response, error) { r, err := c.DoAPIGet(c.cloudRoute()+"/limits", "") if err != nil {