[CLD-7421][CLD-7420] Deprecate Self Serve: First Pass (#26668)

* Deprecate Self Serve: First Pass

* Fix ci

* Fix more ci

* Remmove outdated server tests

* Fix a missed spot opening purchase modal in Self Hosted

* Fix i18n

* Clean up some more server code, fix webapp test

* Fix alignment of button

* Fix linter

* Fix i18n server side

* Add back translation

* Remove client functions

* Put back client functions

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nick Misasi 2024-04-23 14:25:37 -04:00 committed by GitHub
parent a40550136f
commit 437f90e184
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 173 additions and 13211 deletions

View File

@ -13,7 +13,6 @@ import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/audit"
"github.com/mattermost/mattermost/server/v8/platform/shared/web"
)
@ -25,11 +24,6 @@ func (api *API) InitCloud() {
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")
api.BaseRoutes.Cloud.Handle("/payment/confirm", api.APISessionRequired(confirmCustomerPayment)).Methods("POST")
// GET /api/v4/cloud/customer
// PUT /api/v4/cloud/customer
// PUT /api/v4/cloud/customer/address
@ -42,10 +36,6 @@ func (api *API) InitCloud() {
api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods("GET")
api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET")
api.BaseRoutes.Cloud.Handle("/subscription/self-serve-status", api.APISessionRequired(getLicenseSelfServeStatus)).Methods("GET")
api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(changeSubscription)).Methods("PUT")
// GET /api/v4/cloud/request-trial
api.BaseRoutes.Cloud.Handle("/request-trial", api.APISessionRequired(requestCloudTrial)).Methods("PUT")
// GET /api/v4/cloud/validate-business-email
api.BaseRoutes.Cloud.Handle("/validate-business-email", api.APISessionRequired(validateBusinessEmail)).Methods("POST")
@ -59,8 +49,6 @@ func (api *API) InitCloud() {
// GET /api/v4/cloud/cws-health-check
api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods("GET")
api.BaseRoutes.Cloud.Handle("/delete-workspace", api.APISessionRequired(selfServeDeleteWorkspace)).Methods("DELETE")
}
func ensureCloudInterface(c *Context, where string) bool {
@ -134,131 +122,6 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write(json)
}
func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.changeSubscription")
if !ensured {
return
}
userId := c.AppContext.Session().UserId
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.license_error", nil, "", http.StatusInternalServerError)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var subscriptionChange *model.SubscriptionChange
if err = json.Unmarshal(bodyBytes, &subscriptionChange); err != nil || subscriptionChange == nil {
c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
currentSubscription, appErr := c.App.Cloud().GetSubscription(userId)
if appErr != nil {
c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
changedSub, err := c.App.Cloud().ChangeSubscription(userId, currentSubscription.ID, subscriptionChange)
if err != nil {
appErr := model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
if err.Error() == "compliance-failed" {
c.Logger.Error("Compliance check failed", mlog.Err(err))
appErr.StatusCode = http.StatusUnprocessableEntity
}
c.Err = appErr
return
}
if subscriptionChange.Feedback != nil {
c.App.Srv().GetTelemetryService().SendTelemetry("downgrade_feedback", subscriptionChange.Feedback.ToMap())
}
json, err := json.Marshal(changedSub)
if err != nil {
c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
product, err := c.App.Cloud().GetCloudProduct(c.AppContext.Session().UserId, subscriptionChange.ProductID)
if err != nil || product == nil {
c.Logger.Error("Error finding the new cloud product", mlog.Err(err))
}
if product.SKU == string(model.SkuCloudStarter) {
w.Write(json)
return
}
isYearly := product.IsYearly()
// Log failures for purchase confirmation email, but don't show an error to the user so as not to confuse them
// At this point, the upgrade is complete.
if appErr := c.App.SendUpgradeConfirmationEmail(isYearly); appErr != nil {
c.Logger.Error("Error sending purchase confirmation email", mlog.Err(appErr))
}
w.Write(json)
}
func requestCloudTrial(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.requestCloudTrial")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
// check if the email needs to be set
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
// this value will not be empty when both emails (user admin and CWS customer) are not business email and
// a new business email was provided via the request business email modal
var startTrialRequest *model.StartCloudTrialRequest
if err = json.Unmarshal(bodyBytes, &startTrialRequest); err != nil || startTrialRequest == nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
changedSub, err := c.App.Cloud().RequestCloudTrial(c.AppContext.Session().UserId, startTrialRequest.SubscriptionID, startTrialRequest.Email)
if err != nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(changedSub)
if err != nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
defer c.App.Srv().Cloud.InvalidateCaches()
w.Write(json)
}
func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.validateBusinessEmail")
if !ensured {
@ -635,84 +498,6 @@ func updateCloudCustomerAddress(c *Context, w http.ResponseWriter, r *http.Reque
w.Write(json)
}
func createCustomerPayment(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.createCustomerPayment")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
auditRec := c.MakeAuditRecord("createCustomerPayment", audit.Fail)
defer c.LogAuditRec(auditRec)
intent, err := c.App.Cloud().CreateCustomerPayment(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(intent)
if err != nil {
c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.Write(json)
}
func confirmCustomerPayment(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.confirmCustomerPayment")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
auditRec := c.MakeAuditRecord("confirmCustomerPayment", audit.Fail)
defer c.LogAuditRec(auditRec)
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
var confirmRequest *model.ConfirmPaymentMethodRequest
if err = json.Unmarshal(bodyBytes, &confirmRequest); err != nil || confirmRequest == nil {
c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
err = c.App.Cloud().ConfirmCustomerPayment(c.AppContext.Session().UserId, confirmRequest)
if err != nil {
c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getInvoicesForSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getInvoicesForSubscription")
if !ensured {
@ -809,40 +594,6 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
}
switch event.Event {
case model.EventTypeFailedPayment:
if nErr := c.App.SendPaymentFailedEmail(event.FailedPayment); nErr != nil {
c.Err = nErr
return
}
case model.EventTypeFailedPaymentNoCard:
if nErr := c.App.SendNoCardPaymentFailedEmail(); nErr != nil {
c.Err = nErr
return
}
case model.EventTypeSendUpgradeConfirmationEmail:
// isYearly determines whether to send the yearly or monthly Upgrade email
isYearly := false
if event.Subscription != nil && event.CloudWorkspaceOwner != nil {
user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName)
if appErr != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
}
// Get the current cloud product to determine whether it's a monthly or yearly product
product, err := c.App.Cloud().GetCloudProduct(user.Id, event.Subscription.ProductID)
if err != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
isYearly = product.IsYearly()
}
if nErr := c.App.SendUpgradeConfirmationEmail(isYearly); nErr != nil {
c.Err = nErr
return
}
case model.EventTypeSendAdminWelcomeEmail:
user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName)
if appErr != nil {
@ -868,19 +619,6 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
case model.EventTypeTriggerDelinquencyEmail:
var emailToTrigger model.DelinquencyEmail
if event.DelinquencyEmail != nil {
emailToTrigger = model.DelinquencyEmail(event.DelinquencyEmail.EmailToTrigger)
} else {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.delinquency_email.missing_email_to_trigger", nil, "", http.StatusInternalServerError)
return
}
if nErr := c.App.SendDelinquencyEmail(emailToTrigger); nErr != nil {
c.Err = nErr
return
}
default:
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.cws_webhook_event_missing_error", nil, "", http.StatusNotFound)
return
@ -902,37 +640,3 @@ func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request
ReturnStatusOK(w)
}
func selfServeDeleteWorkspace(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.selfServeDeleteWorkspace")
if !ensured {
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.selfServeDeleteWorkspace", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
defer r.Body.Close()
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
var deleteRequest *model.WorkspaceDeletionRequest
if err = json.Unmarshal(bodyBytes, &deleteRequest); err != nil || deleteRequest == nil {
c.Err = model.NewAppError("Api4.selfServeDeleteWorkspace", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := c.App.Cloud().SelfServeDeleteWorkspace(c.AppContext.Session().UserId, deleteRequest); err != nil {
c.Err = model.NewAppError("Api4.selfServeDeleteWorkspace", "api.server.cws.delete_workspace.app_error", nil, "CWS Server failed to delete workspace.", http.StatusInternalServerError)
return
}
c.App.Srv().GetTelemetryService().SendTelemetry("delete_workspace_feedback", deleteRequest.Feedback.ToMap())
ReturnStatusOK(w)
}

View File

@ -16,119 +16,6 @@ import (
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func Test_getCloudLimits(t *testing.T) {
t.Run("no license returns not implemented", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
cloud := &mocks.CloudInterface{}
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, errors.New("Unable to get limits"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = cloud
th.App.Srv().RemoveLicense()
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
limits, r, err := th.Client.GetProductLimits(context.Background())
require.Error(t, err)
require.Nil(t, limits)
require.Equal(t, http.StatusForbidden, r.StatusCode, "Expected 403 forbidden")
})
t.Run("non cloud license returns not implemented", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
cloud := &mocks.CloudInterface{}
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, errors.New("Unable to get limits"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = cloud
th.App.Srv().SetLicense(model.NewTestLicense())
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
limits, r, err := th.Client.GetProductLimits(context.Background())
require.Error(t, err)
require.Nil(t, limits)
require.Equal(t, http.StatusForbidden, r.StatusCode, "Expected 403 forbidden")
})
t.Run("error fetching limits returns internal server error", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := &mocks.CloudInterface{}
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, errors.New("Unable to get limits"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = cloud
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
limits, r, err := th.Client.GetProductLimits(context.Background())
require.Error(t, err)
require.Nil(t, limits)
require.Equal(t, http.StatusInternalServerError, r.StatusCode, "Expected 500 Internal Server Error")
})
t.Run("unauthenticated users can not access", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Logout(context.Background())
limits, r, err := th.Client.GetProductLimits(context.Background())
require.Error(t, err)
require.Nil(t, limits)
require.Equal(t, http.StatusUnauthorized, r.StatusCode, "Expected 401 Unauthorized")
})
t.Run("good request with cloud server", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := &mocks.CloudInterface{}
ten := 10
mockLimits := &model.ProductLimits{
Messages: &model.MessagesLimits{
History: &ten,
},
}
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(mockLimits, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = cloud
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
limits, r, err := th.Client.GetProductLimits(context.Background())
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode, "Expected 200 OK")
require.Equal(t, mockLimits, limits)
require.Equal(t, *mockLimits.Messages.History, *limits.Messages.History)
})
}
func Test_GetSubscription(t *testing.T) {
deliquencySince := int64(2000000000)
@ -215,119 +102,6 @@ func Test_GetSubscription(t *testing.T) {
})
}
func Test_requestTrial(t *testing.T) {
subscription := &model.Subscription{
ID: "MySubscriptionID",
CustomerID: "MyCustomer",
ProductID: "SomeProductId",
AddOns: []string{},
StartAt: 1000000000,
EndAt: 2000000000,
CreateAt: 1000000000,
Seats: 10,
DNS: "some.dns.server",
}
newValidBusinessEmail := model.StartCloudTrialRequest{Email: ""}
t.Run("NON Admin users are UNABLE to request the trial", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloud.Mock.On("RequestCloudTrial", mock.Anything, mock.Anything, "").Return(subscription, nil)
cloud.Mock.On("InvalidateCaches").Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionChanged, r, err := th.Client.RequestCloudTrial(context.Background(), &newValidBusinessEmail)
require.Error(t, err)
require.Nil(t, subscriptionChanged)
require.Equal(t, http.StatusForbidden, r.StatusCode, "403 Forbidden")
})
t.Run("ADMIN user are ABLE to request the trial", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloud.Mock.On("RequestCloudTrial", mock.Anything, mock.Anything, "").Return(subscription, nil)
cloud.Mock.On("InvalidateCaches").Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionChanged, r, err := th.SystemAdminClient.RequestCloudTrial(context.Background(), &newValidBusinessEmail)
require.NoError(t, err)
require.Equal(t, subscriptionChanged, subscription)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
})
t.Run("ADMIN user are ABLE to request the trial with valid business email", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// patch the customer with the additional contact updated with the valid business email
newValidBusinessEmail.Email = *model.NewString("valid.email@mattermost.com")
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloud.Mock.On("RequestCloudTrial", mock.Anything, mock.Anything, "valid.email@mattermost.com").Return(subscription, nil)
cloud.Mock.On("InvalidateCaches").Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionChanged, r, err := th.SystemAdminClient.RequestCloudTrial(context.Background(), &newValidBusinessEmail)
require.NoError(t, err)
require.Equal(t, subscriptionChanged, subscription)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
})
t.Run("Empty body returns bad request", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
r, err := th.SystemAdminClient.DoAPIPutBytes(context.Background(), "/cloud/request-trial", nil)
require.Error(t, err)
closeBody(r)
require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request")
})
}
func Test_validateBusinessEmail(t *testing.T) {
t.Run("Returns forbidden for invalid business email", func(t *testing.T) {
th := Setup(t).InitBasic()
@ -643,58 +417,6 @@ func TestGetCloudProducts(t *testing.T) {
})
}
func Test_GetExpandStatsForSubscription(t *testing.T) {
status := &model.SubscriptionLicenseSelfServeStatusResponse{
IsExpandable: true,
}
licenseId := "licenseID"
t.Run("NON Admin users are UNABLE to request expand stats for the subscription", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetLicenseSelfServeStatus", mock.Anything).Return(status, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
checksMade, r, err := th.Client.GetSubscriptionStatus(context.Background(), licenseId)
require.Error(t, err)
require.Nil(t, checksMade)
require.Equal(t, http.StatusForbidden, r.StatusCode, "403 Forbidden")
})
t.Run("Admin users are UNABLE to request licenses is expendable due missing the id", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetLicenseSelfServeStatus", mock.Anything).Return(status, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
checks, r, err := th.Client.GetSubscriptionStatus(context.Background(), "")
require.Error(t, err)
require.Nil(t, checks)
require.Equal(t, http.StatusBadRequest, r.StatusCode, "400 Bad Request")
})
}
func TestGetSelfHostedProducts(t *testing.T) {
products := []*model.Product{
{

View File

@ -4,20 +4,11 @@
package api4
import (
"bytes"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/web"
)
// APIs for self-hosted workspaces to communicate with the backing customer & payments system.
@ -25,276 +16,12 @@ import (
func (api *API) InitHostedCustomer() {
// POST /api/v4/hosted_customer/available
api.BaseRoutes.HostedCustomer.Handle("/signup_available", api.APISessionRequired(handleSignupAvailable)).Methods("GET")
// POST /api/v4/hosted_customer/bootstrap
api.BaseRoutes.HostedCustomer.Handle("/bootstrap", api.APISessionRequired(selfHostedBootstrap)).Methods("POST")
// POST /api/v4/hosted_customer/customer
api.BaseRoutes.HostedCustomer.Handle("/customer", api.APISessionRequired(selfHostedCustomer)).Methods("POST")
// POST /api/v4/hosted_customer/confirm
api.BaseRoutes.HostedCustomer.Handle("/confirm", api.APISessionRequired(selfHostedConfirm)).Methods("POST")
// POST /api.v4/hosted_customer/confirm-expand
api.BaseRoutes.HostedCustomer.Handle("/confirm-expand", api.APISessionRequired(selfHostedConfirmExpand)).Methods("POST")
// GET /api/v4/hosted_customer/invoices
api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET")
// GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf
api.BaseRoutes.HostedCustomer.Handle("/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(selfHostedInvoicePDF)).Methods("GET")
api.BaseRoutes.HostedCustomer.Handle("/subscribe-newsletter", api.APIHandler(handleSubscribeToNewsletter)).Methods("POST")
}
func ensureSelfHostedAdmin(c *Context, where string) {
ensured := ensureCloudInterface(c, where)
if !ensured {
return
}
license := c.App.Channels().License()
if license.IsCloud() {
c.Err = model.NewAppError(where, "api.cloud.license_error", nil, "Cloud installations do not use this endpoint", http.StatusBadRequest)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
}
func checkSelfHostedPurchaseEnabled(c *Context) bool {
config := c.App.Config()
if config == nil {
return false
}
enabled := config.ServiceSettings.SelfHostedPurchase
return enabled != nil && *enabled
}
func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedBootstrap"
if !checkSelfHostedPurchaseEnabled(c) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented)
return
}
reset := r.URL.Query().Get("reset") == "true"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email, Reset: reset})
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError)
return
}
json, err := json.Marshal(signupProgress)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError)
return
}
w.Write(json)
}
func selfHostedCustomer(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedCustomer"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
if !checkSelfHostedPurchaseEnabled(c) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var form *model.SelfHostedCustomerForm
if err = json.Unmarshal(bodyBytes, &form); err != nil || form == nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
customerResponse, err := c.App.Cloud().CreateCustomerSelfHostedSignup(*form, user.Email)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(customerResponse)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
w.Write(json)
}
func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedConfirm"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
if !checkSelfHostedPurchaseEnabled(c) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var confirm model.SelfHostedConfirmPaymentMethodRequest
err = json.Unmarshal(bodyBytes, &confirm)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email)
if err != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
if err.Error() == strconv.Itoa(http.StatusUnprocessableEntity) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err)
return
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License))
if appErr != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{
License: utils.GetClientLicense(license),
Progress: confirmResponse.Progress,
})
if err != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
go func() {
err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication()
if err != nil {
c.Logger.Warn("Unable to confirm license application", mlog.Err(err))
}
}()
_, _ = w.Write(clientResponse)
}
func handleSignupAvailable(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.handleSignupAvailable"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
if !checkSelfHostedPurchaseEnabled(c) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented)
return
}
if err := c.App.Cloud().SelfHostedSignupAvailable(); err != nil {
if err.Error() == "upstream_off" {
c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusServiceUnavailable)
} else {
c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusNotImplemented)
}
return
}
systemValue, err := c.App.Srv().Store().System().GetByName(model.SystemHostedPurchaseNeedsScreening)
if err == nil && systemValue != nil {
c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusTooEarly)
return
}
ReturnStatusOK(w)
}
func selfHostedInvoices(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedInvoices"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
invoices, err := c.App.Cloud().GetSelfHostedInvoices(c.AppContext)
if err != nil {
if err.Error() == "404" {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotFound).Wrap(errors.New("invoices for license not found"))
return
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(invoices)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
w.Write(json)
}
func selfHostedInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedInvoicePDF"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
pdfData, filename, appErr := c.App.Cloud().GetSelfHostedInvoicePDF(c.Params.InvoiceId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
web.WriteFileResponse(
filename,
"application/pdf",
int64(binary.Size(pdfData)),
time.Now(),
*c.App.Config().ServiceSettings.WebserverMode,
bytes.NewReader(pdfData),
false,
w,
r,
)
c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusNotImplemented)
}
func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Request) {
@ -326,80 +53,3 @@ func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Requ
ReturnStatusOK(w)
}
func selfHostedConfirmExpand(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedConfirmExpand"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
if !checkSelfHostedPurchaseEnabled(c) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var confirm model.SelfHostedConfirmPaymentMethodRequest
err = json.Unmarshal(bodyBytes, &confirm)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
confirmResponse, err := c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email)
if err != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
if err.Error() == strconv.Itoa(http.StatusUnprocessableEntity) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err)
return
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License))
// dealing with an AppError
if appErr != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{
License: utils.GetClientLicense(license),
Progress: confirmResponse.Progress,
})
if err != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
go func() {
err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication()
if err != nil {
c.Logger.Warn("Unable to confirm license application", mlog.Err(err))
}
}()
_, _ = w.Write(clientResponse)
}

View File

@ -1,145 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
var valFalse = false
var valTrue = true
func TestSelfHostedBootstrap(t *testing.T) {
t.Run("feature flag off returns not implemented", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
cloud := mocks.CloudInterface{}
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "false")
defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE")
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valFalse })
th.App.ReloadConfig()
_, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email})
require.Equal(t, http.StatusNotImplemented, r.StatusCode)
require.Error(t, err)
})
t.Run("cloud instances not allowed to bootstrap self-hosted signup", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
cloud := mocks.CloudInterface{}
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true")
defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE")
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue })
th.App.ReloadConfig()
_, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email})
require.Equal(t, http.StatusBadRequest, r.StatusCode)
require.Error(t, err)
})
t.Run("non-admins not allowed to bootstrap self-hosted signup", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
cloud := mocks.CloudInterface{}
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true")
defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE")
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue })
th.App.ReloadConfig()
_, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email})
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.Error(t, err)
})
t.Run("self-hosted admins can bootstrap self-hosted signup", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true")
defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE")
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue })
th.App.ReloadConfig()
cloud := mocks.CloudInterface{}
cloud.Mock.On("BootstrapSelfHostedSignup", mock.Anything).Return(&model.BootstrapSelfHostedSignupResponse{Progress: "START"}, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
response, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email})
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, err)
require.Equal(t, "START", response.Progress)
})
t.Run("team edition returns bad request instead of panicking", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = nil
th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true")
defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE")
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue })
th.App.ReloadConfig()
_, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email})
require.Equal(t, http.StatusBadRequest, r.StatusCode)
require.Error(t, err)
})
}

View File

@ -325,8 +325,6 @@ type AppIface interface {
SearchAllChannels(c request.CTX, term string, opts model.ChannelSearchOpts) (model.ChannelListWithTeamData, int64, *model.AppError)
// SearchAllTeams returns a team list and the total count of the results
SearchAllTeams(searchOpts *model.TeamSearch) ([]*model.Team, int64, *model.AppError)
// SendNoCardPaymentFailedEmail
SendNoCardPaymentFailedEmail() *model.AppError
// SessionHasPermissionToChannels returns true only if user has access to all channels.
SessionHasPermissionToChannels(c request.CTX, session model.Session, channelIDs []string, permission *model.Permission) bool
// SessionHasPermissionToManageBot returns nil if the session has access to manage the given bot.
@ -595,7 +593,6 @@ type AppIface interface {
DoLocalRequest(c request.CTX, rawURL string, body []byte) (*http.Response, *model.AppError)
DoLogin(c request.CTX, w http.ResponseWriter, r *http.Request, user *model.User, deviceID string, isMobile, isOAuthUser, isSaml bool) (*model.Session, *model.AppError)
DoPostActionWithCookie(c request.CTX, postID, actionId, userID, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError)
DoSubscriptionRenewalCheck()
DoSystemConsoleRolesCreationMigration()
DoUploadFile(c request.CTX, now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte, extractContent bool) (*model.FileInfo, *model.AppError)
DoUploadFileExpectModification(c request.CTX, now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte, extractContent bool) (*model.FileInfo, []byte, *model.AppError)
@ -1086,18 +1083,15 @@ type AppIface interface {
SendAckToPushProxy(ack *model.PushNotificationAck) error
SendAutoResponse(rctx request.CTX, channel *model.Channel, receiver *model.User, post *model.Post) (bool, *model.AppError)
SendAutoResponseIfNecessary(rctx request.CTX, channel *model.Channel, sender *model.User, post *model.Post) (bool, *model.AppError)
SendDelinquencyEmail(emailToSend model.DelinquencyEmail) *model.AppError
SendEmailVerification(user *model.User, newEmail, redirect string) *model.AppError
SendEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post
SendIPFiltersChangedEmail(c request.CTX, userID string) error
SendNotifications(c request.CTX, post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error)
SendNotifyAdminPosts(c request.CTX, workspaceName string, currentSKU string, trial bool) *model.AppError
SendPasswordReset(email string, siteURL string) (bool, *model.AppError)
SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError
SendPersistentNotifications() error
SendReportToUser(rctx request.CTX, job *model.Job, format string) *model.AppError
SendTestPushNotification(deviceID string) string
SendUpgradeConfirmationEmail(isYearly bool) *model.AppError
ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string)
SessionHasPermissionTo(session model.Session, permission *model.Permission) bool
SessionHasPermissionToAny(session model.Session, permissions []*model.Permission) bool

View File

@ -4,135 +4,9 @@
package app
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
func getCurrentPlanName(a *App) (string, *model.AppError) {
subscription, err := a.Cloud().GetSubscription("")
if err != nil {
return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if subscription == nil {
return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError)
}
products, err := a.Cloud().GetCloudProducts("", false)
if err != nil {
return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_cloud_products.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if products == nil {
return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_cloud_products.app_error", nil, "", http.StatusInternalServerError)
}
planName := getCurrentProduct(subscription.ProductID, products).Name
return planName, nil
}
func (a *App) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError {
sysAdmins, err := a.getAllSystemAdmins()
if err != nil {
return err
}
planName, err := getCurrentPlanName(a)
if err != nil {
return model.NewAppError("SendPaymentFailedEmail", "app.cloud.get_current_plan_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, admin := range sysAdmins {
_, err := a.Srv().EmailService.SendPaymentFailedEmail(admin.Email, admin.Locale, failedPayment, planName, *a.Config().ServiceSettings.SiteURL)
if err != nil {
a.Log().Error("Error sending payment failed email", mlog.Err(err))
}
}
return nil
}
func getCurrentProduct(subscriptionProductID string, products []*model.Product) *model.Product {
for _, product := range products {
if product.ID == subscriptionProductID {
return product
}
}
return nil
}
func (a *App) SendDelinquencyEmail(emailToSend model.DelinquencyEmail) *model.AppError {
sysAdmins, aErr := a.getAllSystemAdmins()
if aErr != nil {
return aErr
}
planName, aErr := getCurrentPlanName(a)
if aErr != nil {
return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_current_plan_name.app_error", nil, "", http.StatusInternalServerError).Wrap(aErr)
}
subscription, err := a.Cloud().GetSubscription("")
if err != nil {
return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if subscription == nil {
return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError)
}
if subscription.DelinquentSince == nil {
return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_subscription_delinquency_date.app_error", nil, "", http.StatusInternalServerError)
}
delinquentSince := time.Unix(*subscription.DelinquentSince, 0)
delinquencyDate := delinquentSince.Format("01/02/2006")
for _, admin := range sysAdmins {
switch emailToSend {
case model.DelinquencyEmail7:
err := a.Srv().EmailService.SendDelinquencyEmail7(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName)
if err != nil {
a.Log().Error("Error sending delinquency email 7", mlog.Err(err))
}
case model.DelinquencyEmail14:
err := a.Srv().EmailService.SendDelinquencyEmail14(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName)
if err != nil {
a.Log().Error("Error sending delinquency email 14", mlog.Err(err))
}
case model.DelinquencyEmail30:
err := a.Srv().EmailService.SendDelinquencyEmail30(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName)
if err != nil {
a.Log().Error("Error sending delinquency email 30", mlog.Err(err))
}
case model.DelinquencyEmail45:
err := a.Srv().EmailService.SendDelinquencyEmail45(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName, delinquencyDate)
if err != nil {
a.Log().Error("Error sending delinquency email 45", mlog.Err(err))
}
case model.DelinquencyEmail60:
err := a.Srv().EmailService.SendDelinquencyEmail60(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL)
if err != nil {
a.Log().Error("Error sending delinquency email 60", mlog.Err(err))
}
case model.DelinquencyEmail75:
err := a.Srv().EmailService.SendDelinquencyEmail75(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName, delinquencyDate)
if err != nil {
a.Log().Error("Error sending delinquency email 75", mlog.Err(err))
}
case model.DelinquencyEmail90:
err := a.Srv().EmailService.SendDelinquencyEmail90(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL)
if err != nil {
a.Log().Error("Error sending delinquency email 90", mlog.Err(err))
}
}
}
return nil
}
func (a *App) AdjustInProductLimits(limits *model.ProductLimits, subscription *model.Subscription) *model.AppError {
if limits.Teams != nil && limits.Teams.Active != nil && *limits.Teams.Active > 0 {
err := a.AdjustTeamsFromProductLimits(limits.Teams)
@ -144,86 +18,6 @@ func (a *App) AdjustInProductLimits(limits *model.ProductLimits, subscription *m
return nil
}
func getNextBillingDateString() string {
now := time.Now()
t := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC)
return fmt.Sprintf("%s %d, %d", t.Month(), t.Day(), t.Year())
}
func (a *App) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError {
sysAdmins, e := a.getAllSystemAdmins()
if e != nil {
return e
}
if len(sysAdmins) == 0 {
return model.NewAppError("app.SendCloudUpgradeConfirmationEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError)
}
subscription, err := a.Cloud().GetSubscription("")
if err != nil {
return model.NewAppError("app.SendCloudUpgradeConfirmationEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError)
}
billingDate := getNextBillingDateString()
// we want to at least have one email sent out to an admin
countNotOks := 0
embeddedFiles := make(map[string]io.Reader)
if isYearly {
lastInvoice := subscription.LastInvoice
if lastInvoice == nil {
a.Log().Error("Last invoice not defined for the subscription", mlog.String("subscription", subscription.ID))
} else {
pdf, filename, pdfErr := a.Cloud().GetInvoicePDF("", lastInvoice.ID)
if pdfErr != nil {
a.Log().Error("Error retrieving the invoice for subscription id", mlog.String("subscription", subscription.ID), mlog.Err(pdfErr))
} else {
embeddedFiles = map[string]io.Reader{
filename: bytes.NewReader(pdf),
}
}
}
}
for _, admin := range sysAdmins {
name := admin.FirstName
if name == "" {
name = admin.Username
}
err := a.Srv().EmailService.SendCloudUpgradeConfirmationEmail(admin.Email, name, billingDate, admin.Locale, *a.Config().ServiceSettings.SiteURL, subscription.GetWorkSpaceNameFromDNS(), isYearly, embeddedFiles)
if err != nil {
a.Log().Error("Error sending trial ended email to", mlog.String("email", admin.Email), mlog.Err(err))
countNotOks++
}
}
// if not even one admin got an email, we consider that this operation errored
if countNotOks == len(sysAdmins) {
return model.NewAppError("app.SendCloudUpgradeConfirmationEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError)
}
return nil
}
// SendNoCardPaymentFailedEmail
func (a *App) SendNoCardPaymentFailedEmail() *model.AppError {
sysAdmins, err := a.getAllSystemAdmins()
if err != nil {
return err
}
for _, admin := range sysAdmins {
err := a.Srv().EmailService.SendNoCardPaymentFailedEmail(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL)
if err != nil {
a.Log().Error("Error sending payment failed email", mlog.Err(err))
}
}
return nil
}
// Create/ Update a subscription history event
func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) {
license := a.Srv().License()
@ -240,106 +34,3 @@ func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHi
}
return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount))
}
func (a *App) DoSubscriptionRenewalCheck() {
if !a.License().IsCloud() || !a.Config().FeatureFlags.CloudAnnualRenewals {
return
}
subscription, err := a.Cloud().GetSubscription("")
if err != nil {
a.Log().Error("Error getting subscription", mlog.Err(err))
return
}
if subscription == nil {
a.Log().Error("Subscription not found")
return
}
if subscription.IsFreeTrial == "true" {
return // Don't send renewal emails for free trials
}
if model.BillingType(subscription.BillingType) == model.BillingTypeLicensed || model.BillingType(subscription.BillingType) == model.BillingTypeInternal {
return // Don't send renewal emails for licensed or internal billing
}
sysVar, err := a.Srv().Store().System().GetByName(model.CloudRenewalEmail)
if err != nil {
// We only care about the error if it wasn't a not found error
if _, ok := err.(*store.ErrNotFound); !ok {
a.Log().Error(err.Error())
}
}
prevSentEmail := int64(0)
if sysVar != nil {
// We don't care about parse errors because it's possible the value is empty, and we've already defaulted to 0
prevSentEmail, _ = strconv.ParseInt(sysVar.Value, 10, 64)
}
if subscription.WillRenew == "true" {
// They've already completed the renewal process so no need to email them.
// We can zero out the system variable so that this process will work again next year
if prevSentEmail != 0 {
sysVar.Value = "0"
err = a.Srv().Store().System().SaveOrUpdate(sysVar)
if err != nil {
a.Log().Error("Error saving system variable", mlog.Err(err))
}
}
return
}
var emailFunc func(email, locale, siteURL string) error
daysToExpiration := subscription.DaysToExpiration()
// Only send the email if within the period and it's not already been sent
// This allows the email to send on day 59 if for whatever reason it was unable to on day 60
if daysToExpiration <= 60 && daysToExpiration > 30 && prevSentEmail != 60 && !(prevSentEmail < 60) {
emailFunc = a.Srv().EmailService.SendCloudRenewalEmail60
prevSentEmail = 60
} else if daysToExpiration <= 30 && daysToExpiration > 7 && prevSentEmail != 30 && !(prevSentEmail < 30) {
emailFunc = a.Srv().EmailService.SendCloudRenewalEmail30
prevSentEmail = 30
} else if daysToExpiration <= 7 && daysToExpiration >= 0 && prevSentEmail != 7 {
emailFunc = a.Srv().EmailService.SendCloudRenewalEmail7
prevSentEmail = 7
}
if emailFunc == nil {
return
}
sysAdmins, aErr := a.getAllSystemAdmins()
if aErr != nil {
a.Log().Error("Error getting sys admins", mlog.Err(aErr))
return
}
numFailed := 0
for _, admin := range sysAdmins {
err = emailFunc(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL)
if err != nil {
a.Log().Error("Error sending renewal email", mlog.Err(err))
numFailed += 1
}
}
if numFailed == len(sysAdmins) {
// If all emails failed, we don't want to update the system variable
return
}
updatedSysVar := &model.System{
Name: model.CloudRenewalEmail,
Value: strconv.FormatInt(prevSentEmail, 10),
}
err = a.Srv().Store().System().SaveOrUpdate(updatedSysVar)
if err != nil {
a.Log().Error("Error saving system variable", mlog.Err(err))
}
}

View File

@ -239,44 +239,6 @@ func (es *Service) SendWelcomeEmail(userID string, email string, verified bool,
return nil
}
func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, locale, siteURL, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.cloud_upgrade_confirmation.subject")
data := es.NewEmailTemplateData(locale)
data.Props["Title"] = T("api.templates.cloud_upgrade_confirmation.title")
data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_monthly.subtitle", map[string]any{"WorkspaceName": workspaceName, "Date": date})
data.Props["SiteURL"] = siteURL
data.Props["ButtonURL"] = siteURL
data.Props["Button"] = T("api.templates.cloud_welcome_email.button")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
if isYearly {
data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_yearly.subtitle", map[string]any{"WorkspaceName": workspaceName})
data.Props["ButtonURL"] = siteURL + "/admin_console/billing/billing_history"
data.Props["Button"] = T("api.templates.cloud_welcome_email.yearly_plan_button")
}
body, err := es.templatesContainer.RenderToString("cloud_upgrade_confirmation", data)
if err != nil {
return err
}
if isYearly {
if err := es.SendMailWithEmbeddedFilesAndCustomReplyTo(userEmail, subject, body, *es.config().SupportSettings.SupportEmail, embeddedFiles, "CloudUpgradeConfirmationEmail"); err != nil {
return err
}
} else {
if err := es.sendEmailWithCustomReplyTo(userEmail, subject, body, *es.config().SupportSettings.SupportEmail, "CloudUpgradeConfirmationEmail"); err != nil {
return err
}
}
return nil
}
// SendCloudWelcomeEmail sends the cloud version of the welcome email
func (es *Service) SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error {
T := i18n.GetUserTranslations(locale)
@ -972,378 +934,6 @@ func (es *Service) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, ct
return nil
}
func (es *Service) SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, planName, siteURL string) (bool, error) {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.payment_failed.subject", map[string]any{"Plan": planName})
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.payment_failed.title")
data.Props["SubTitle1"] = T("api.templates.payment_failed.info1", map[string]any{"CardBrand": failedPayment.CardBrand, "LastFour": failedPayment.LastFour})
data.Props["SubTitle2"] = T("api.templates.payment_failed.info2")
data.Props["FailedReason"] = failedPayment.FailureMessage
data.Props["SubTitle3"] = T("api.templates.payment_failed.info3", map[string]any{"Plan": planName})
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_45.button")
data.Props["IncludeSecondaryActionButton"] = false
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("payment_failed_body", data)
if err != nil {
return false, err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "PaymentFailed"); err != nil {
return false, err
}
return true, nil
}
func (es *Service) SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.payment_failed_no_card.subject")
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.payment_failed_no_card.title")
data.Props["Info1"] = T("api.templates.payment_failed_no_card.info1")
data.Props["Info3"] = T("api.templates.payment_failed_no_card.info3")
data.Props["Button"] = T("api.templates.payment_failed_no_card.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("payment_failed_no_card_body", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "NoCardPaymentFailed"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail7(email, locale, siteURL, planName string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.payment_failed.subject", map[string]any{"Plan": planName})
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_7.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_7.subtitle1")
data.Props["SubTitle2"] = T("api.templates.delinquency_7.subtitle2", map[string]any{"Plan": planName})
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_7.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("cloud_7_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency7"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail14(email, locale, siteURL, planName string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.delinquency_14.subject", map[string]any{"Plan": planName})
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_14.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_14.subtitle1")
data.Props["SubTitle2"] = T("api.templates.delinquency_14.subtitle2")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_14.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("cloud_14_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency14"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail30(email, locale, siteURL, planName string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.delinquency_30.subject", map[string]any{"Plan": planName})
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_30.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_30.subtitle1", map[string]any{"Plan": planName})
data.Props["SubTitle2"] = T("api.templates.delinquency_30.subtitle2")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_30.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["BulletListItems"] = []string{T("api.templates.delinquency_30.bullet.message_history"), T("api.templates.delinquency_30.bullet.files")}
data.Props["LimitsDocs"] = T("api.templates.delinquency_30.limits_documentation")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("cloud_30_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency30"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail45(email, locale, siteURL, planName, delinquencyDate string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.delinquency_45.subject", map[string]any{"Plan": planName})
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_45.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_45.subtitle1", map[string]any{"DelinquencyDate": delinquencyDate})
data.Props["SubTitle2"] = T("api.templates.delinquency_45.subtitle2")
data.Props["SubTitle3"] = T("api.templates.delinquency_45.subtitle3")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_45.button")
data.Props["IncludeSecondaryActionButton"] = false
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("cloud_45_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency45"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail60(email, locale, siteURL string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.delinquency_60.subject")
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_60.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_60.subtitle1")
data.Props["SubTitle2"] = T("api.templates.delinquency_60.subtitle2")
data.Props["SubTitle3"] = T("api.templates.delinquency_60.subtitle3")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_60.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["IncludeSecondaryActionButton"] = true
data.Props["SecondaryActionButtonText"] = T("api.templates.delinquency_60.downgrade_to_free")
data.Props["Footer"] = T("api.templates.copyright")
// 45 day template is the same as the 60 day one so its reused
body, err := es.templatesContainer.RenderToString("cloud_45_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency60"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail75(email, locale, siteURL, planName, delinquencyDate string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.delinquency_75.subject", map[string]any{"Plan": planName})
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_75.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_75.subtitle1", map[string]any{"DelinquencyDate": delinquencyDate})
data.Props["SubTitle2"] = T("api.templates.delinquency_75.subtitle2", map[string]any{"Plan": planName})
data.Props["SubTitle3"] = T("api.templates.delinquency_75.subtitle3")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_75.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["IncludeSecondaryActionButton"] = true
data.Props["SecondaryActionButtonText"] = T("api.templates.delinquency_75.downgrade_to_free")
data.Props["Footer"] = T("api.templates.copyright")
// 45 day template is the same as the 75 day one so its reused
body, err := es.templatesContainer.RenderToString("cloud_45_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency75"); err != nil {
return err
}
return nil
}
func (es *Service) SendDelinquencyEmail90(email, locale, siteURL string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.delinquency_90.subject")
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.delinquency_90.title")
data.Props["SubTitle1"] = T("api.templates.delinquency_90.subtitle1", map[string]any{"SiteURL": siteURL})
data.Props["SubTitle2"] = T("api.templates.delinquency_90.subtitle2")
data.Props["SubTitle3"] = T("api.templates.delinquency_90.subtitle3")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["Button"] = T("api.templates.delinquency_90.button")
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["IncludeSecondaryActionButton"] = true
data.Props["SecondaryActionButtonText"] = T("api.templates.delinquency_90.secondary_action_button")
data.Props["Footer"] = T("api.templates.copyright")
body, err := es.templatesContainer.RenderToString("cloud_90_day_arrears", data)
if err != nil {
return err
}
if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency90"); err != nil {
return err
}
return nil
}
func (es *Service) SendCloudRenewalEmail60(email, locale, siteURL string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.cloud_renewal_60.subject")
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.cloud_renewal_60.title")
data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle")
// TODO: use the open delinquency modal action
data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription"
data.Props["Button"] = T("api.templates.cloud_renewal.button")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Image"] = "payment_processing.png"
body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data)
if err != nil {
return err
}
if err := es.sendMail(email, subject, body, "CloudRenewal60"); err != nil {
return err
}
return nil
}
func (es *Service) SendCloudRenewalEmail30(email, locale, siteURL string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.cloud_renewal_30.subject")
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.cloud_renewal_30.title")
data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle")
// TODO: use the open delinquency modal action
data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription"
data.Props["Button"] = T("api.templates.cloud_renewal.button")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Image"] = "payment_processing.png"
body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data)
if err != nil {
return err
}
if err := es.sendMail(email, subject, body, "CloudRenewal30"); err != nil {
return err
}
return nil
}
func (es *Service) SendCloudRenewalEmail7(email, locale, siteURL string) error {
T := i18n.GetUserTranslations(locale)
subject := T("api.templates.cloud_renewal_7.subject")
data := es.NewEmailTemplateData(locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = T("api.templates.cloud_renewal_7.title")
data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle")
// TODO: use the open delinquency modal action
data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription"
data.Props["Button"] = T("api.templates.cloud_renewal.button")
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
data.Props["EmailUs"] = T("api.templates.email_us_anytime_at")
data.Props["Image"] = "purchase_alert.png"
body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data)
if err != nil {
return err
}
if err := es.sendMail(email, subject, body, "CloudRenewal7"); err != nil {
return err
}
return nil
}
// SendRemoveExpiredLicenseEmail formats an email and uses the email service to send the email to user with link pointing to CWS
// to renew the user license
func (es *Service) SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error {

View File

@ -4,10 +4,7 @@
package email
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
@ -251,83 +248,6 @@ func TestSendInviteEmails(t *testing.T) {
})
}
func TestSendCloudUpgradedEmail(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.ConfigureInbucketMail()
emailTo := "testclouduser@example.com"
emailToUsername := strings.Split(emailTo, "@")[0]
t.Run("SendCloudMonthlyUpgradedEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "You'll be billed from", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "Open Mattermost", "Wrong received message %s", resultsEmail.Body.Text)
require.Len(t, resultsEmail.Attachments, 0)
}
mail.DeleteMailBox(emailTo)
// Send Update to Monthly Plan email
err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", false, make(map[string]io.Reader))
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendCloudYearlyUpgradedEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "View your invoice", "Wrong received message %s", resultsEmail.Body.Text)
require.Len(t, resultsEmail.Attachments, 1)
}
mail.DeleteMailBox(emailTo)
// Send Update to Monthly Plan email
var embeddedFiles = map[string]io.Reader{
"filename": bytes.NewReader([]byte("Test")),
}
err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", true, embeddedFiles)
require.NoError(t, err)
verifyMailbox(t)
})
}
func TestSendCloudWelcomeEmail(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()

View File

@ -182,78 +182,6 @@ func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 stri
return r0
}
// SendCloudRenewalEmail30 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendCloudRenewalEmail30(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendCloudRenewalEmail30")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudRenewalEmail60 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendCloudRenewalEmail60(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendCloudRenewalEmail60")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudRenewalEmail7 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendCloudRenewalEmail7(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendCloudRenewalEmail7")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles
func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error {
ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles)
if len(ret) == 0 {
panic("no return value specified for SendCloudUpgradeConfirmationEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string, string, bool, map[string]io.Reader) error); ok {
r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudWelcomeEmail provides a mock function with given fields: userEmail, locale, teamInviteID, workSpaceName, dns, siteURL
func (_m *ServiceInterface) SendCloudWelcomeEmail(userEmail string, locale string, teamInviteID string, workSpaceName string, dns string, siteURL string) error {
ret := _m.Called(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
@ -290,132 +218,6 @@ func (_m *ServiceInterface) SendDeactivateAccountEmail(_a0 string, locale string
return r0
}
// SendDelinquencyEmail14 provides a mock function with given fields: _a0, locale, siteURL, planName
func (_m *ServiceInterface) SendDelinquencyEmail14(_a0 string, locale string, siteURL string, planName string) error {
ret := _m.Called(_a0, locale, siteURL, planName)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail14")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail30 provides a mock function with given fields: _a0, locale, siteURL, planName
func (_m *ServiceInterface) SendDelinquencyEmail30(_a0 string, locale string, siteURL string, planName string) error {
ret := _m.Called(_a0, locale, siteURL, planName)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail30")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail45 provides a mock function with given fields: _a0, locale, siteURL, planName, delinquencyDate
func (_m *ServiceInterface) SendDelinquencyEmail45(_a0 string, locale string, siteURL string, planName string, delinquencyDate string) error {
ret := _m.Called(_a0, locale, siteURL, planName, delinquencyDate)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail45")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName, delinquencyDate)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail60 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDelinquencyEmail60(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail60")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail7 provides a mock function with given fields: _a0, locale, siteURL, planName
func (_m *ServiceInterface) SendDelinquencyEmail7(_a0 string, locale string, siteURL string, planName string) error {
ret := _m.Called(_a0, locale, siteURL, planName)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail7")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail75 provides a mock function with given fields: _a0, locale, siteURL, planName, delinquencyDate
func (_m *ServiceInterface) SendDelinquencyEmail75(_a0 string, locale string, siteURL string, planName string, delinquencyDate string) error {
ret := _m.Called(_a0, locale, siteURL, planName, delinquencyDate)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail75")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName, delinquencyDate)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail90 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDelinquencyEmail90(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendDelinquencyEmail90")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeEmail provides a mock function with given fields: oldEmail, newEmail, locale, siteURL
func (_m *ServiceInterface) SendEmailChangeEmail(oldEmail string, newEmail string, locale string, siteURL string) error {
ret := _m.Called(oldEmail, newEmail, locale, siteURL)
@ -590,24 +392,6 @@ func (_m *ServiceInterface) SendMfaChangeEmail(_a0 string, activated bool, local
return r0
}
// SendNoCardPaymentFailedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendNoCardPaymentFailedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendNoCardPaymentFailedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendNotificationMail provides a mock function with given fields: to, subject, htmlBody
func (_m *ServiceInterface) SendNotificationMail(to string, subject string, htmlBody string) error {
ret := _m.Called(to, subject, htmlBody)
@ -672,34 +456,6 @@ func (_m *ServiceInterface) SendPasswordResetEmail(_a0 string, token *model.Toke
return r0, r1
}
// SendPaymentFailedEmail provides a mock function with given fields: _a0, locale, failedPayment, planName, siteURL
func (_m *ServiceInterface) SendPaymentFailedEmail(_a0 string, locale string, failedPayment *model.FailedPayment, planName string, siteURL string) (bool, error) {
ret := _m.Called(_a0, locale, failedPayment, planName, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendPaymentFailedEmail")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(string, string, *model.FailedPayment, string, string) (bool, error)); ok {
return rf(_a0, locale, failedPayment, planName, siteURL)
}
if rf, ok := ret.Get(0).(func(string, string, *model.FailedPayment, string, string) bool); ok {
r0 = rf(_a0, locale, failedPayment, planName, siteURL)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(string, string, *model.FailedPayment, string, string) error); ok {
r1 = rf(_a0, locale, failedPayment, planName, siteURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendRemoveExpiredLicenseEmail provides a mock function with given fields: ctaText, ctaLink, _a2, locale, siteURL
func (_m *ServiceInterface) SendRemoveExpiredLicenseEmail(ctaText string, ctaLink string, _a2 string, locale string, siteURL string) error {
ret := _m.Called(ctaText, ctaLink, _a2, locale, siteURL)

View File

@ -134,7 +134,6 @@ type ServiceInterface interface {
SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error
SendSignInChangeEmail(email, method, locale, siteURL string) error
SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error
SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error
SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error
SendPasswordChangeEmail(email, method, locale, siteURL string) error
SendUserAccessTokenAddedEmail(email, locale, siteURL string) error
@ -147,19 +146,6 @@ type ServiceInterface interface {
SendNotificationMail(to, subject, htmlBody string) error
SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error
SendLicenseUpForRenewalEmail(email, name, locale, siteURL, ctaTitle, ctaLink, ctaText string, daysToExpiration int) error
SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, planName, siteURL string) (bool, error)
// Cloud delinquency email sequence
SendDelinquencyEmail7(email, locale, siteURL, planName string) error
SendDelinquencyEmail14(email, locale, siteURL, planName string) error
SendDelinquencyEmail30(email, locale, siteURL, planName string) error
SendDelinquencyEmail45(email, locale, siteURL, planName, delinquencyDate string) error
SendDelinquencyEmail60(email, locale, siteURL string) error
SendDelinquencyEmail75(email, locale, siteURL, planName, delinquencyDate string) error
SendDelinquencyEmail90(email, locale, siteURL string) error
SendCloudRenewalEmail60(email, locale, siteURL string) error
SendCloudRenewalEmail30(email, locale, siteURL string) error
SendCloudRenewalEmail7(email, locale, siteURL string) error
SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error
SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error
AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError
GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string

View File

@ -4088,21 +4088,6 @@ func (a *OpenTracingAppLayer) DoPostActionWithCookie(c request.CTX, postID strin
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) DoSubscriptionRenewalCheck() {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoSubscriptionRenewalCheck")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
a.app.DoSubscriptionRenewalCheck()
}
func (a *OpenTracingAppLayer) DoSystemConsoleRolesCreationMigration() {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoSystemConsoleRolesCreationMigration")
@ -15956,28 +15941,6 @@ func (a *OpenTracingAppLayer) SendAutoResponseIfNecessary(rctx request.CTX, chan
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) SendDelinquencyEmail(emailToSend model.DelinquencyEmail) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendDelinquencyEmail")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.SendDelinquencyEmail(emailToSend)
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) SendEmailVerification(user *model.User, newEmail string, redirect string) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendEmailVerification")
@ -16039,28 +16002,6 @@ func (a *OpenTracingAppLayer) SendIPFiltersChangedEmail(c request.CTX, userID st
return resultVar0
}
func (a *OpenTracingAppLayer) SendNoCardPaymentFailedEmail() *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendNoCardPaymentFailedEmail")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.SendNoCardPaymentFailedEmail()
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) SendNotifications(c request.CTX, post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendNotifications")
@ -16127,28 +16068,6 @@ func (a *OpenTracingAppLayer) SendPasswordReset(email string, siteURL string) (b
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendPaymentFailedEmail")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.SendPaymentFailedEmail(failedPayment)
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) SendPersistentNotifications() error {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendPersistentNotifications")
@ -16232,28 +16151,6 @@ func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string {
return resultVar0
}
func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendUpgradeConfirmationEmail")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.SendUpgradeConfirmationEmail(isYearly)
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId string, destinationPluginId string) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ServeInterPluginRequest")

View File

@ -1364,12 +1364,6 @@ func (s *Server) doLicenseExpirationCheck() {
return
}
if license.IsCloud() {
appInstance := New(ServerConnector(s.Channels()))
appInstance.DoSubscriptionRenewalCheck()
return
}
users, err := s.Store().User().GetSystemAdminProfiles()
if err != nil {
mlog.Error("Failed to get system admins for license expired message from Mattermost.")

View File

@ -555,10 +555,6 @@
"id": "api.cloud.cws_webhook_event_missing_error",
"translation": "Webhook event was not handled. Either it is missing or it is not valid."
},
{
"id": "api.cloud.delinquency_email.missing_email_to_trigger",
"translation": "Missing required fields to send delinquency email."
},
{
"id": "api.cloud.license_error",
"translation": "Your license does not support cloud requests."
@ -2838,10 +2834,6 @@
"id": "api.scheme.patch_scheme.license.error",
"translation": "Your license does not support update permissions schemes"
},
{
"id": "api.server.cws.delete_workspace.app_error",
"translation": "CWS Server failed to delete workspace."
},
{
"id": "api.server.cws.disabled",
"translation": "Interactions with the Mattermost Customer Portal have been disabled by the system admin."
@ -3262,54 +3254,6 @@
"id": "api.team.user.missing_account",
"translation": "Unable to find the user."
},
{
"id": "api.templates.cloud_renewal.button",
"translation": "Renew now"
},
{
"id": "api.templates.cloud_renewal.subtitle",
"translation": "Please renew to avoid any disruption"
},
{
"id": "api.templates.cloud_renewal_30.subject",
"translation": "Annual bill due in 30 days"
},
{
"id": "api.templates.cloud_renewal_30.title",
"translation": "Your annual bill is due in 30 days"
},
{
"id": "api.templates.cloud_renewal_60.subject",
"translation": "Annual subscription renewal in 60 days"
},
{
"id": "api.templates.cloud_renewal_60.title",
"translation": "Annual subscription renewal in 60 days"
},
{
"id": "api.templates.cloud_renewal_7.subject",
"translation": "Action Required: Annual subscription renewal in 7 days"
},
{
"id": "api.templates.cloud_renewal_7.title",
"translation": "You are about to lose access to your workspace in 7 days"
},
{
"id": "api.templates.cloud_upgrade_confirmation.subject",
"translation": "Mattermost Upgrade Confirmation"
},
{
"id": "api.templates.cloud_upgrade_confirmation.title",
"translation": "You are now upgraded!"
},
{
"id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle",
"translation": "Your {{.WorkspaceName}} workspace has now been upgraded. You'll be billed from {{.Date}}"
},
{
"id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle",
"translation": "Your {{.WorkspaceName}} workspace has now been upgraded."
},
{
"id": "api.templates.cloud_welcome_email.add_apps_info",
"translation": "Add apps to your workspace"
@ -3378,14 +3322,6 @@
"id": "api.templates.cloud_welcome_email.title",
"translation": "Your workspace is ready to go!"
},
{
"id": "api.templates.cloud_welcome_email.yearly_plan_button",
"translation": "View your invoice"
},
{
"id": "api.templates.copyright",
"translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301"
},
{
"id": "api.templates.deactivate_body.info",
"translation": "You deactivated your account on {{ .SiteURL }}."
@ -3402,182 +3338,6 @@
"id": "api.templates.deactivate_subject",
"translation": "[{{ .SiteName }}] Your account at {{ .ServerURL }} has been deactivated"
},
{
"id": "api.templates.delinquency_14.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_14.subject",
"translation": "Payment is overdue for your Mattermost {{.Plan}}"
},
{
"id": "api.templates.delinquency_14.subtitle1",
"translation": "We weren't able to charge the credit card we have on file. This means your workspace is at risk of being downgraded to Cloud Free."
},
{
"id": "api.templates.delinquency_14.subtitle2",
"translation": "Please contact your financial institution to resolve any issues. Then, update your payment details as needed."
},
{
"id": "api.templates.delinquency_14.title",
"translation": "Payment not received"
},
{
"id": "api.templates.delinquency_30.bullet.files",
"translation": "Files"
},
{
"id": "api.templates.delinquency_30.bullet.message_history",
"translation": "Message history"
},
{
"id": "api.templates.delinquency_30.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_30.limits_documentation",
"translation": "View all limits documentation."
},
{
"id": "api.templates.delinquency_30.subject",
"translation": "Act to keep your Mattermost {{.Plan}} Features"
},
{
"id": "api.templates.delinquency_30.subtitle1",
"translation": "You have time to keep your Mattermost {{.Plan}} active but you'll need to resolve issues with your payment method."
},
{
"id": "api.templates.delinquency_30.subtitle2",
"translation": "If no action is taken, your workspace will be downgraded and the following data may be archived:"
},
{
"id": "api.templates.delinquency_30.title",
"translation": "Your workspace will be downgraded soon"
},
{
"id": "api.templates.delinquency_45.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_45.subject",
"translation": "Notice: Your Mattermost {{.Plan}} will be downgraded soon"
},
{
"id": "api.templates.delinquency_45.subtitle1",
"translation": "We've been unable to collect payment for outstanding invoices since {{.DelinquencyDate}}. Your workspace is at risk of being downgraded."
},
{
"id": "api.templates.delinquency_45.subtitle2",
"translation": "A downgraded workspace might negatively affect critical workflows and other business critical activities carried at your workspace."
},
{
"id": "api.templates.delinquency_45.subtitle3",
"translation": "Update your credit card information now."
},
{
"id": "api.templates.delinquency_45.title",
"translation": "Your workspace will be downgraded soon"
},
{
"id": "api.templates.delinquency_60.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_60.downgrade_to_free",
"translation": "Downgrade to Cloud Free"
},
{
"id": "api.templates.delinquency_60.subject",
"translation": "Action Required: Workspace will be downgraded in 30 days"
},
{
"id": "api.templates.delinquency_60.subtitle1",
"translation": "Please update your payment information soon to process your outstanding invoices."
},
{
"id": "api.templates.delinquency_60.subtitle2",
"translation": "We will downgrade your workspace automatically in 30 days if we are unable to process your payment."
},
{
"id": "api.templates.delinquency_60.subtitle3",
"translation": "Update your payment information now or downgrade to Cloud Free below."
},
{
"id": "api.templates.delinquency_60.title",
"translation": "Your Mattermost workspace will be downgraded in 30 days"
},
{
"id": "api.templates.delinquency_7.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_7.subtitle1",
"translation": "We couldn't process your most recent payment."
},
{
"id": "api.templates.delinquency_7.subtitle2",
"translation": "To keep your {{.Plan}} plan active, please contact your financial institution as soon as possible. Then, update your payment details as needed."
},
{
"id": "api.templates.delinquency_7.title",
"translation": "Your payment wasn't completed"
},
{
"id": "api.templates.delinquency_75.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_75.downgrade_to_free",
"translation": "Downgrade to Cloud Free"
},
{
"id": "api.templates.delinquency_75.subject",
"translation": "Your Mattermost {{.Plan}} will be downgraded in 15 days"
},
{
"id": "api.templates.delinquency_75.subtitle1",
"translation": "This is a final reminder that we havent received payment for your Mattermost Cloud workspace since {{.DelinquencyDate}}."
},
{
"id": "api.templates.delinquency_75.subtitle2",
"translation": "Your workspace will be downgraded to Cloud Free. Your {{.Plan}} features will be locked and some of your workspace data may be archived until your full outstanding balance is settled."
},
{
"id": "api.templates.delinquency_75.subtitle3",
"translation": "Update your payment information now, or downgrade to Cloud Free."
},
{
"id": "api.templates.delinquency_75.title",
"translation": "Your workspace will be downgraded in 15 days"
},
{
"id": "api.templates.delinquency_90.button",
"translation": "Update payment"
},
{
"id": "api.templates.delinquency_90.secondary_action_button",
"translation": "View Plans & Pricing"
},
{
"id": "api.templates.delinquency_90.subject",
"translation": "Your Mattermost Cloud workspace has been downgraded"
},
{
"id": "api.templates.delinquency_90.subtitle1",
"translation": "If you use Cloud Professional or Enterprise features for important business operations, these will no longer be available and you'll experience degraded performance."
},
{
"id": "api.templates.delinquency_90.subtitle2",
"translation": "In addition, your data may have been archived due to Cloud Free limitations."
},
{
"id": "api.templates.delinquency_90.subtitle3",
"translation": "To unarchive your data and keep paid features, update your payment information."
},
{
"id": "api.templates.delinquency_90.title",
"translation": "Your Mattermost workspace has been downgraded"
},
{
"id": "api.templates.email_change_body.info",
"translation": "Your email address for {{.TeamDisplayName}} has been changed to {{.NewEmail}}."
@ -3782,46 +3542,6 @@
"id": "api.templates.password_change_subject",
"translation": "[{{ .SiteName }}] Your password has been updated"
},
{
"id": "api.templates.payment_failed.info1",
"translation": "Your financial institution declined a payment from your {{.CardBrand}} ****{{.LastFour}} associated with your Mattermost Cloud workspace."
},
{
"id": "api.templates.payment_failed.info2",
"translation": "They provided the following reason:"
},
{
"id": "api.templates.payment_failed.info3",
"translation": "To ensure uninterrupted access to Mattermost {{.Plan}}, please either contact your financial institution to fix the underlying problem or update your payment information. Once payment information is updated, Mattermost will attempt to settle any outstanding balance."
},
{
"id": "api.templates.payment_failed.subject",
"translation": "Action required: Payment failed for Mattermost {{.Plan}}"
},
{
"id": "api.templates.payment_failed.title",
"translation": "The payment wasn't successful"
},
{
"id": "api.templates.payment_failed_no_card.button",
"translation": "Pay now"
},
{
"id": "api.templates.payment_failed_no_card.info1",
"translation": "Your Mattermost Cloud invoice for the most recent billing period has been processed. However, we don't have your payment details on file."
},
{
"id": "api.templates.payment_failed_no_card.info3",
"translation": "To review your invoice and add a payment method, select Pay now."
},
{
"id": "api.templates.payment_failed_no_card.subject",
"translation": "Payment is due for your Mattermost Cloud subscription"
},
{
"id": "api.templates.payment_failed_no_card.title",
"translation": "Your Mattermost Cloud Invoice is due"
},
{
"id": "api.templates.post_body.button",
"translation": "Reply in Mattermost"
@ -5074,22 +4794,6 @@
"id": "app.channel_member_history.log_leave_event.internal_error",
"translation": "Failed to record channel member history. Failed to update existing join record"
},
{
"id": "app.cloud.get_cloud_products.app_error",
"translation": "Couldn't retrieve cloud products"
},
{
"id": "app.cloud.get_current_plan_name.app_error",
"translation": "Unable to get current plan name"
},
{
"id": "app.cloud.get_subscription.app_error",
"translation": "Couldn't retrieve cloud subscription"
},
{
"id": "app.cloud.get_subscription_delinquency_date.app_error",
"translation": "Subscription is not delinquent"
},
{
"id": "app.cloud.trial_plan_bot_message",
"translation": "{{.UsersNum}} members of the {{.WorkspaceName}} workspace have requested starting the Enterprise trial for access to: "
@ -7214,10 +6918,6 @@
"id": "app.user.send_auto_response.app_error",
"translation": "Unable to send auto response from user."
},
{
"id": "app.user.send_emails.app_error",
"translation": "No emails were successfully sent"
},
{
"id": "app.user.store_is_empty.app_error",
"translation": "Failed to check if user store is empty."

View File

@ -45,8 +45,6 @@ import BillingHistory, {searchableStrings as billingHistorySearchableStrings} fr
import BillingSubscriptions, {searchableStrings as billingSubscriptionSearchableStrings} from './billing/billing_subscriptions';
import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} from './billing/company_info';
import CompanyInfoEdit from './billing/company_info_edit';
import PaymentInfo, {searchableStrings as billingPaymentInfoSearchableStrings} from './billing/payment_info';
import PaymentInfoEdit from './billing/payment_info_edit';
import BleveSettings, {searchableStrings as bleveSearchableStrings} from './bleve_settings';
import BrandImageSetting from './brand_image_setting/brand_image_setting';
import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings';
@ -385,30 +383,6 @@ const AdminDefinition: AdminDefinitionType = {
isHidden: it.not(it.licensedForFeature('Cloud')),
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
},
payment_info: {
url: 'billing/payment_info',
title: defineMessage({id: 'admin.sidebar.payment_info', defaultMessage: 'Payment Information'}),
isHidden: it.any(
it.hidePaymentInfo,
// cloud only view
it.not(it.licensedForFeature('Cloud')),
),
searchableStrings: billingPaymentInfoSearchableStrings,
schema: {
id: 'PaymentInfo',
component: PaymentInfo,
},
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
},
payment_info_edit: {
url: 'billing/payment_info_edit',
schema: {
id: 'PaymentInfoEdit',
component: PaymentInfoEdit,
},
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
},
},
},
reporting: {

View File

@ -1,108 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {unixTimestampFromNow} from 'tests/helpers/date';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {CloudAnnualRenewalBanner} from './billing_subscriptions';
describe('CloudAnnualRenewalBanner', () => {
const initialState = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
cancel_at: 1652807380,
},
products: {
test_prod_1: {
id: 'test_prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
},
test_prod_2: {
id: 'test_prod_2',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 0,
},
test_prod_3: {
id: 'test_prod_3',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 0,
},
},
},
},
};
it('should not render if subscription is not available', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = null;
const {queryByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(queryByText(/Your annual subscription expires in/)).not.toBeInTheDocument();
});
it('should render with correct title and buttons', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(30),
};
const {getByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your annual subscription expires in 30 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
const renewButton = getByText(/Renew/);
renewButton.click();
});
it('should render with danger mode if expiration is within 7 days', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(4),
};
const {getByText, getByTestId} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your annual subscription expires in 4 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument();
});
it('should render with with different title when end_at time has passed', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(-5),
cancel_at: unixTimestampFromNow(5),
};
const {getByText, getByTestId} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your subscription has expired. Your workspace will be deleted in 5 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument();
});
});

View File

@ -2,16 +2,10 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import BlockableLink from 'components/admin_console/blockable_link';
import type {ModeType} from 'components/alert_banner';
import AlertBanner from 'components/alert_banner';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {daysToCancellation, daysToExpiration} from 'utils/cloud_utils';
export const creditCardExpiredBanner = (setShowCreditCardBanner: (value: boolean) => void) => {
return (
@ -59,61 +53,3 @@ export const paymentFailedBanner = () => {
/>
);
};
export const CloudAnnualRenewalBanner = () => {
const openPurchaseModal = useOpenCloudPurchaseModal({});
const subscription = useGetSubscription();
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
if (!subscription || !subscription.cancel_at || (subscription.will_renew === 'true' && !subscription.delinquent_since)) {
return null;
}
const daysUntilExpiration = daysToExpiration(subscription);
const daysUntilCancelation = daysToCancellation(subscription);
const renewButton = (
<button
className='btn btn-primary'
onClick={() => openPurchaseModal({})}
>
{formatMessage({id: 'cloud_annual_renewal.banner.buttonText.renew', defaultMessage: 'Renew'})}
</button>
);
const contactSalesButton = (
<button
className='btn btn-tertiary'
onClick={openSalesLink}
>
{formatMessage({id: 'cloud_annual_renewal.banner.buttonText.contactSales', defaultMessage: 'Contact Sales'})}
</button>
);
const alertBannerProps = {
mode: 'info' as ModeType,
title: (<>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title', defaultMessage: 'Your annual subscription expires in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilExpiration})}</>),
actionButtonLeft: renewButton,
actionButtonRight: contactSalesButton,
message: <></>,
};
// If outside the 60 day window or on a trial, don't show this banner.
if (daysUntilExpiration > 60 || subscription.is_free_trial === 'true') {
return null;
}
if (daysUntilExpiration <= 7) {
alertBannerProps.mode = 'danger';
}
if (daysUntilExpiration <= 0) {
alertBannerProps.title = <>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired', defaultMessage: 'Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilCancelation})}</>;
}
return (
<AlertBanner
id={'cloud_annual_renewal_alert_banner_' + alertBannerProps.mode}
{...alertBannerProps}
/>
);
};

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect} from 'react';
import {FormattedMessage, defineMessages} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
@ -11,7 +11,6 @@ import {getCloudSubscription, getCloudProducts, getCloudCustomer} from 'mattermo
import {
getSubscriptionProduct,
getCloudSubscription as selectCloudSubscription,
getCloudCustomer as selectCloudCustomer,
getCloudErrors,
} from 'mattermost-redux/selectors/entities/cloud';
@ -19,29 +18,16 @@ import {pageVisited} from 'actions/telemetry_actions';
import CloudTrialBanner from 'components/admin_console/billing/billing_subscriptions/cloud_trial_banner';
import CloudFetchError from 'components/cloud_fetch_error';
import useGetLimits from 'components/common/hooks/useGetLimits';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {isCustomerCardExpired} from 'utils/cloud_utils';
import {
TrialPeriodDays,
} from 'utils/constants';
import {useQuery} from 'utils/http_utils';
import {hasSomeLimits} from 'utils/limits';
import {getRemainingDaysFromFutureTimestamp} from 'utils/utils';
import {
CloudAnnualRenewalBanner,
creditCardExpiredBanner,
paymentFailedBanner,
} from './billing_subscriptions';
import CancelSubscription from './cancel_subscription';
import ContactSalesCard from './contact_sales_card';
import LimitReachedBanner from './limit_reached_banner';
import Limits from './limits';
import {ToPaidNudgeBanner} from './to_paid_plan_nudge_banner';
import BillingSummary from '../billing_summary';
import PlanDetails from '../plan_details';
@ -59,18 +45,11 @@ export const searchableStrings = [
const BillingSubscriptions = () => {
const dispatch = useDispatch();
const subscription = useSelector(selectCloudSubscription);
const [cloudLimits] = useGetLimits();
const errorLoadingData = useSelector((state: GlobalState) => {
const errors = getCloudErrors(state);
return Boolean(errors.limits || errors.subscription || errors.customer || errors.products);
});
const isCardExpired = isCustomerCardExpired(useSelector(selectCloudCustomer));
const trialEndDate = subscription?.trial_end_at || 0;
const [showCreditCardBanner, setShowCreditCardBanner] = useState(true);
const query = useQuery();
const actionQueryParam = query.get('action');
@ -78,13 +57,6 @@ const BillingSubscriptions = () => {
const openPricingModal = useOpenPricingModal();
const openCloudPurchaseModal = useOpenCloudPurchaseModal({});
// show the upgrade section when is a free tier customer
const onUpgradeMattermostCloud = (callerInfo: string) => {
openCloudPurchaseModal({trackingLocation: callerInfo});
};
let isFreeTrial = false;
let daysLeftOnTrial = 0;
if (subscription?.is_free_trial === 'true') {
@ -103,23 +75,11 @@ const BillingSubscriptions = () => {
pageVisited('cloud_admin', 'pageview_billing_subscription');
if (actionQueryParam === 'show_purchase_modal') {
onUpgradeMattermostCloud('billing_subscriptions_external_direct_link');
}
if (actionQueryParam === 'show_pricing_modal') {
openPricingModal({trackingLocation: 'billing_subscriptions_external_direct_link'});
}
if (actionQueryParam === 'show_delinquency_modal') {
openCloudPurchaseModal({trackingLocation: 'billing_subscriptions_external_direct_link'});
}
}, []);
const shouldShowPaymentFailedBanner = () => {
return subscription?.last_invoice?.status === 'failed';
};
// handle not loaded yet here, failed to load handled below
if ((!subscription || !product) && !errorLoadingData) {
return null;
@ -134,15 +94,6 @@ const BillingSubscriptions = () => {
<div className='admin-console__content'>
{errorLoadingData && <CloudFetchError/>}
{!errorLoadingData && <>
<LimitReachedBanner
product={product}
/>
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
{<CloudAnnualRenewalBanner/>}
{<ToPaidNudgeBanner/>}
{showCreditCardBanner &&
isCardExpired &&
creditCardExpiredBanner(setShowCreditCardBanner)}
{isFreeTrial && <CloudTrialBanner trialEndDate={trialEndDate}/>}
<div className='BillingSubscriptions__topWrapper'>
<PlanDetails
@ -152,19 +103,13 @@ const BillingSubscriptions = () => {
<BillingSummary
isFreeTrial={isFreeTrial}
daysLeftOnTrial={daysLeftOnTrial}
onUpgradeMattermostCloud={onUpgradeMattermostCloud}
/>
</div>
{hasSomeLimits(cloudLimits) && !isFreeTrial ? (
<Limits/>
) : (
<ContactSalesCard
isFreeTrial={isFreeTrial}
subscriptionPlan={product?.sku}
onUpgradeMattermostCloud={openPricingModal}
/>
)}
<CancelSubscription/>
<ContactSalesCard
isFreeTrial={isFreeTrial}
subscriptionPlan={product?.sku}
onUpgradeMattermostCloud={openPricingModal}
/>
</>}
</div>
</div>

View File

@ -1,28 +0,0 @@
@import 'utils/mixins';
.LimitReachedBanner {
&__actions {
padding-top: 12px;
}
&__primary {
font-size: 12px;
@include primary-button;
&:hover {
color: var(--button-color);
}
}
&__contact-sales {
margin-left: 4px;
font-size: 12px;
@include tertiary-button;
&:hover {
color: var(--button-bg);
}
}
}

View File

@ -1,193 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {GlobalState} from '@mattermost/types/store';
import type {UserProfile, UsersState} from '@mattermost/types/users';
import {Preferences} from 'mattermost-redux/constants';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import * as useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas';
import * as useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import * as useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import * as useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import * as useSaveBool from 'components/common/hooks/useSavePreferences';
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import LimitReachedBanner from './limit_reached_banner';
const upgradeCloudKey = getPreferenceKey(Preferences.CATEGORY_UPGRADE_CLOUD, Preferences.SYSTEM_CONSOLE_LIMIT_REACHED);
const state: GlobalState = {
entities: {
users: {
currentUserId: 'userid',
profiles: {
userid: {} as UserProfile,
},
} as unknown as UsersState,
preferences: {
myPreferences: {
[upgradeCloudKey]: {value: 'false'},
},
},
cloud: {
limits: {
},
products: {
},
},
general: {
license: {
},
config: {
},
},
},
} as GlobalState;
const base = {
id: '',
name: '',
description: '',
price_per_seat: 0,
add_ons: [],
product_family: '',
billing_scheme: '',
recurring_interval: '',
cross_sells_to: '',
};
const free = {...base, sku: CloudProducts.STARTER};
const enterprise = {...base, sku: CloudProducts.ENTERPRISE};
const noLimitReached = {
files: {
totalStorage: -1,
totalStorageLoaded: true,
},
messages: {
history: -1,
historyLoaded: true,
},
boards: {
cards: -1,
cardsLoaded: true,
},
teams: {
active: -1,
cloudArchived: -1,
teamsLoaded: true,
},
integrations: {
enabled: -1,
enabledLoaded: true,
},
};
const someLimitReached = {
...noLimitReached,
integrations: {
...noLimitReached.integrations,
enabled: 1,
},
};
const titleFree = /Upgrade to one of our paid plans to avoid/;
const titleProfessional = /Upgrade to Enterprise to avoid Professional plan/;
function makeSpies() {
const mockUseOpenSalesLink = jest.spyOn(useOpenSalesLink, 'default');
const mockUseGetUsageDeltas = jest.spyOn(useGetUsageDeltas, 'default');
const mockUseOpenCloudPurchaseModal = jest.spyOn(useOpenCloudPurchaseModal, 'default');
const mockUseOpenPricingModal = jest.spyOn(useOpenPricingModal, 'default');
const mockUseSaveBool = jest.spyOn(useSaveBool, 'useSaveBool');
return {
useOpenSalesLink: mockUseOpenSalesLink,
useGetUsageDeltas: mockUseGetUsageDeltas,
useOpenCloudPurchaseModal: mockUseOpenCloudPurchaseModal,
useOpenPricingModal: mockUseOpenPricingModal,
useSaveBool: mockUseSaveBool,
};
}
describe('limits_reached_banner', () => {
test('does not render when product is enterprise', () => {
const spies = makeSpies();
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={enterprise}/>, state);
expect(screen.queryByText(titleFree)).not.toBeInTheDocument();
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
});
test('does not render when banner was dismissed', () => {
const myState = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[upgradeCloudKey]: {value: 'true'},
},
},
},
};
const spies = makeSpies();
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={enterprise}/>, myState);
expect(screen.queryByText(titleFree)).not.toBeInTheDocument();
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
});
test('does not render when no limit reached', () => {
const spies = makeSpies();
spies.useGetUsageDeltas.mockReturnValue(noLimitReached);
renderWithContext(<LimitReachedBanner product={free}/>, state);
expect(screen.queryByText(titleFree)).not.toBeInTheDocument();
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
});
test('renders free banner', () => {
const spies = makeSpies();
const mockOpenPricingModal = jest.fn();
spies.useOpenPricingModal.mockReturnValue(mockOpenPricingModal);
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={free}/>, state);
screen.getByText(titleFree);
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
fireEvent.click(screen.getByText('View plans'));
expect(mockOpenPricingModal).toHaveBeenCalled();
});
test('clicking Contact Sales opens sales link', () => {
const spies = makeSpies();
const mockOpenSalesLink = jest.fn();
spies.useOpenSalesLink.mockReturnValue([mockOpenSalesLink, '']);
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={free}/>, state);
screen.getByText(titleFree);
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Contact sales'));
expect(mockOpenSalesLink).toHaveBeenCalled();
});
});

View File

@ -1,105 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import type {Product} from '@mattermost/types/cloud';
import {Preferences} from 'mattermost-redux/constants';
import {getHasDismissedSystemConsoleLimitReached} from 'mattermost-redux/selectors/entities/preferences';
import AlertBanner from 'components/alert_banner';
import useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useSaveBool} from 'components/common/hooks/useSavePreferences';
import {CloudProducts} from 'utils/constants';
import {anyUsageDeltaExceededLimit} from 'utils/limits';
import './limit_reached_banner.scss';
interface Props {
product?: Product;
}
const LimitReachedBanner = (props: Props) => {
const intl = useIntl();
const someLimitExceeded = anyUsageDeltaExceededLimit(useGetUsageDeltas());
const hasDismissedBanner = useSelector(getHasDismissedSystemConsoleLimitReached);
const [openSalesLink] = useOpenSalesLink();
const openPricingModal = useOpenPricingModal();
const saveBool = useSaveBool();
if (hasDismissedBanner || !someLimitExceeded || !props.product || (props.product.sku !== CloudProducts.STARTER)) {
return null;
}
const title = (
<FormattedMessage
id='workspace_limits.banner_upgrade.free'
defaultMessage='Upgrade to one of our paid plans to avoid {planName} plan data limits'
values={{
planName: props.product.name,
}}
/>
);
const description = (
<FormattedMessage
id='workspace_limits.banner_upgrade_reason.free'
defaultMessage='Your workspace has exceeded {planName} plan data limits. Upgrade to a paid plan for additional capacity.'
values={{
planName: props.product.name,
}}
/>
);
const upgradeMessage = {
id: 'workspace_limits.modals.view_plans',
defaultMessage: 'View plans',
};
const upgradeAction = () => openPricingModal({trackingLocation: 'limit_reached_banner'});
const onDismiss = () => {
saveBool({
category: Preferences.CATEGORY_UPGRADE_CLOUD,
name: Preferences.SYSTEM_CONSOLE_LIMIT_REACHED,
value: true,
});
};
return (
<AlertBanner
mode='danger'
title={title}
message={description}
onDismiss={onDismiss}
className='LimitReachedBanner'
>
<div className='LimitReachedBanner__actions'>
<button
onClick={upgradeAction}
className='btn LimitReachedBanner__primary'
>
{intl.formatMessage(upgradeMessage)}
</button>
<button
onClick={openSalesLink}
className='btn LimitReachedBanner__contact-sales'
>
{intl.formatMessage({
id: 'admin.license.trialCard.contactSales',
defaultMessage: 'Contact sales',
})}
</button>
</div>
</AlertBanner>
);
};
export default LimitReachedBanner;

View File

@ -6,7 +6,7 @@ import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {ToPaidNudgeBanner, ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
import {ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
const initialState = {
views: {
@ -171,68 +171,3 @@ describe('ToPaidPlanBannerDismissable', () => {
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
});
describe('ToPaidNudgeBanner', () => {
test('should show only for cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithContext(<ToPaidNudgeBanner/>, state, {useMockedStore: true});
screen.getByTestId('cloud-free-deprecation-alert-banner');
});
test('should NOT show for cloud professional', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
},
},
};
renderWithContext(<ToPaidNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
test('should NOT show for cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_ent',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_ent: {
id: 'prod_ent',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithContext(<ToPaidNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
});

View File

@ -3,7 +3,7 @@
import moment from 'moment';
import React, {useEffect} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
@ -13,11 +13,8 @@ import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-re
import {deprecateCloudFree, get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import AlertBanner from 'components/alert_banner';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import {t} from 'utils/i18n';
@ -174,73 +171,3 @@ export const ToPaidPlanBannerDismissable = () => {
/>
);
};
export const ToPaidNudgeBanner = () => {
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const product = useSelector(selectSubscriptionProduct);
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
if (!cloudFreeDeprecated) {
return null;
}
if (!currentProductStarter) {
return null;
}
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const title = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.title'
defaultMessage='Upgrade to paid plan to keep your workspace'
/>
);
const description = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.description'
defaultMessage='Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.'
values={{days: daysToCloudFreeEnd < 0 ? 0 : daysToCloudFreeEnd}}
/>
);
const viewPlansAction = (
<button
onClick={() => openPurchaseModal({trackingLocation: 'to_paid_plan_nudge_banner'})}
className='btn ToPaidNudgeBanner__primary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.learn_more', defaultMessage: 'Upgrade'})}
</button>
);
const contactSalesAction = (
<button
onClick={openSalesLink}
className='btn ToPaidNudgeBanner__secondary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.contact_sales', defaultMessage: 'Contact sales'})}
</button>
);
const bannerMode = (daysToCloudFreeEnd <= 10) ? 'danger' : 'info';
return (
<AlertBanner
id='cloud-free-deprecation-alert-banner'
mode={bannerMode}
title={title}
message={description}
className='ToYearlyNudgeBanner'
actionButtonLeft={viewPlansAction}
actionButtonRight={contactSalesAction}
/>
);
};

View File

@ -5,7 +5,7 @@ import React from 'react';
import {FormattedDate, FormattedMessage, FormattedNumber, defineMessages} from 'react-intl';
import {useDispatch} from 'react-redux';
import {CheckCircleOutlineIcon, CheckIcon, ClockOutlineIcon} from '@mattermost/compass-icons/components';
import {CheckCircleOutlineIcon} from '@mattermost/compass-icons/components';
import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud';
import {Client4} from 'mattermost-redux/client';
@ -15,6 +15,7 @@ import {openModal} from 'actions/views/modals';
import BlockableLink from 'components/admin_console/blockable_link';
import CloudInvoicePreview from 'components/cloud_invoice_preview';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import EmptyBillingHistorySvg from 'components/common/svg_images_components/empty_billing_history_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import ExternalLink from 'components/external_link';
@ -65,118 +66,81 @@ export const noBillingHistory = (
</div>
);
export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void, daysLeftOnTrial: number, reverseTrial: boolean) => (
<div className='UpgradeMattermostCloud'>
<div className='UpgradeMattermostCloud__image'>
<UpgradeSvg
height={167}
width={234}
type FreeTrialProps = {
daysLeftOnTrial: number;
}
export const FreeTrial = ({daysLeftOnTrial}: FreeTrialProps) => {
const [openSalesLink] = useOpenSalesLink();
return (
<div className='UpgradeMattermostCloud'>
<div className='UpgradeMattermostCloud__image'>
<UpgradeSvg
height={167}
width={234}
/>
</div>
<div className='UpgradeMattermostCloud__title'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.title'
defaultMessage={'You\'re currently on a free trial'}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.title'
defaultMessage={'Your free trial ends today'}
/>
}
</div>
<div className='UpgradeMattermostCloud__description'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.description'
defaultMessage='Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lessThan3Days.description'
defaultMessage='Your free trial will end in {daysLeftOnTrial, number} {daysLeftOnTrial, plural, one {day} other {days}}. Add payment information to continue enjoying the benefits of Cloud Professional.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.description'
defaultMessage='Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.'
/>
}
</div>
<button
type='button'
onClick={() => openSalesLink()}
className='UpgradeMattermostCloud__upgradeButton'
>
<FormattedMessage
id='admin.billing.subscription.privateCloudCard.contactSales'
defaultMessage='Contact Sales'
/>
</button>
</div>);
};
export const getPaymentStatus = () => {
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckCircleOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.paid'
defaultMessage='Paid'
/>
</div>
<div className='UpgradeMattermostCloud__title'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.title'
defaultMessage={'You\'re currently on a free trial'}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.title'
defaultMessage={'Your free trial ends today'}
/>
}
</div>
<div className='UpgradeMattermostCloud__description'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.description'
defaultMessage='Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lessThan3Days.description'
defaultMessage='Your free trial will end in {daysLeftOnTrial, number} {daysLeftOnTrial, plural, one {day} other {days}}. Add payment information to continue enjoying the benefits of Cloud Professional.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.description'
defaultMessage='Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.'
/>
}
</div>
<button
type='button'
onClick={() => onUpgradeMattermostCloud('billing_summary_free_trial_upgrade_button')}
className='UpgradeMattermostCloud__upgradeButton'
>
{
reverseTrial ? (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.purchaseButton'
defaultMessage='Purchase Now'
/>
) : (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.subscribeButton'
defaultMessage='Upgrade Now'
/>
)
}
</button>
</div>
);
export const getPaymentStatus = (status: string, willRenew?: boolean) => {
if (willRenew) {
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.approved'
defaultMessage='Approved'
/>
</div>
);
}
switch (status.toLowerCase()) {
case 'failed':
return (
<div className='BillingSummary__lastInvoice-headerStatus failed'>
<i className='icon icon-alert-outline'/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.failed'
defaultMessage='Failed'
/>
</div>
);
case 'paid':
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckCircleOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.paid'
defaultMessage='Paid'
/>
</div>
);
default:
return (
<div className='BillingSummary__lastInvoice-headerStatus pending'>
<ClockOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.pending'
defaultMessage='Pending'
/>
</div>
);
}
);
};
type InvoiceInfoProps = {
@ -185,10 +149,9 @@ type InvoiceInfoProps = {
fullCharges: InvoiceLineItem[];
partialCharges: InvoiceLineItem[];
hasMore?: number;
willRenew?: boolean;
}
export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore, willRenew}: InvoiceInfoProps) => {
export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore}: InvoiceInfoProps) => {
const dispatch = useDispatch();
const isUpcomingInvoice = invoice?.status.toLowerCase() === 'upcoming';
@ -225,7 +188,7 @@ export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasM
<div className='BillingSummary__lastInvoice-headerTitle'>
{title()}
</div>
{getPaymentStatus(invoice.status, willRenew)}
{getPaymentStatus()}
</div>
<div className='BillingSummary__lastInvoice-date'>
<FormattedDate

View File

@ -2,83 +2,30 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import {getSubscriptionProduct, checkHadPriorTrial, getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
import {cloudReverseTrial} from 'mattermost-redux/selectors/entities/preferences';
import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils';
import {CloudProducts} from 'utils/constants';
import {
noBillingHistory,
InvoiceInfo,
freeTrial,
FreeTrial,
} from './billing_summary';
import {tryEnterpriseCard, UpgradeToProfessionalCard} from './upsell_card';
import './billing_summary.scss';
type BillingSummaryProps = {
isFreeTrial: boolean;
daysLeftOnTrial: number;
onUpgradeMattermostCloud: (callerInfo: string) => void;
}
const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}: BillingSummaryProps) => {
const subscription = useSelector(getCloudSubscription);
const product = useSelector(getSubscriptionProduct);
const reverseTrial = useSelector(cloudReverseTrial);
export default function BillingSummary({isFreeTrial, daysLeftOnTrial}: BillingSummaryProps) {
let body = noBillingHistory;
const isPreTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at === 0;
const hasPriorTrial = useSelector(checkHadPriorTrial);
const isStarterPreTrial = product?.sku === CloudProducts.STARTER && isPreTrial;
const isStarterPostTrial = product?.sku === CloudProducts.STARTER && hasPriorTrial;
if (isStarterPreTrial && reverseTrial) {
body = <UpgradeToProfessionalCard/>;
} else if (isStarterPreTrial) {
body = tryEnterpriseCard;
} else if (isStarterPostTrial) {
body = <UpgradeToProfessionalCard/>;
} else if (isFreeTrial) {
body = freeTrial(onUpgradeMattermostCloud, daysLeftOnTrial, reverseTrial);
} else if (subscription?.last_invoice && !subscription?.upcoming_invoice) {
const invoice = subscription.last_invoice;
const fullCharges = invoice.line_items.filter((item) => item.type === 'full');
const partialCharges = invoice.line_items.filter((item) => item.type === 'partial');
body = (
<InvoiceInfo
invoice={invoice}
product={product}
fullCharges={fullCharges}
partialCharges={partialCharges}
/>
);
} else if (subscription?.upcoming_invoice) {
const invoice = subscription.upcoming_invoice;
const {fullCharges, partialCharges, hasMore} = buildInvoiceSummaryPropsFromLineItems(invoice.line_items);
body = (
<InvoiceInfo
invoice={invoice}
product={product}
fullCharges={fullCharges}
partialCharges={partialCharges}
hasMore={hasMore}
willRenew={subscription?.will_renew === 'true'}
/>
);
if (isFreeTrial) {
// eslint-disable-next-line new-cap
body = FreeTrial({daysLeftOnTrial});
}
return (
<div className='BillingSummary'>
{body}
</div>
);
};
}
export default BillingSummary;

View File

@ -6,7 +6,6 @@ import React from 'react';
import {useIntl} from 'react-intl';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg';
import StartTrialCaution from 'components/pricing_modal/start_trial_caution';
@ -31,9 +30,6 @@ const enterpriseAdvantages = [
},
];
// Currently, these are the same. In the future, they may diverge.
const professionalAdvantages = enterpriseAdvantages;
interface Props {
advantages: Message[];
title: Message;
@ -149,26 +145,6 @@ export const tryEnterpriseCard = (
/>
);
export const UpgradeToProfessionalCard = () => {
const openPurchaseModal = useOpenCloudPurchaseModal({});
return (
<UpsellCard
title={{
id: t('admin.billing.subscriptions.billing_summary.upgrade_professional'),
defaultMessage: 'Upgrade to the Professional Plan',
}}
cta={{
id: t('admin.billing.subscriptions.billing_summary.upgrade_professional.cta'),
defaultMessage: 'Upgrade',
}}
ctaAction={() => openPurchaseModal({trackingLocation: 'billing_summary_upsell_professional_card'})}
ctaPrimary={true}
andMore={true}
advantages={professionalAdvantages}
/>
);
};
export const ExploreEnterpriseCard = () => {
return (
<UpsellCard

View File

@ -1,3 +0,0 @@
.PaymentInfo .AlertBanner {
margin-bottom: 20px;
}

View File

@ -1,98 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, defineMessages} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {getCloudCustomer} from 'mattermost-redux/actions/cloud';
import {getCloudErrors} from 'mattermost-redux/selectors/entities/cloud';
import {pageVisited} from 'actions/telemetry_actions';
import AlertBanner from 'components/alert_banner';
import CloudFetchError from 'components/cloud_fetch_error';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import PaymentInfoDisplay from './payment_info_display';
import './payment_info.scss';
type Props = Record<string, never>;
const messages = defineMessages({
title: {id: 'admin.billing.payment_info.title', defaultMessage: 'Payment Information'},
});
export const searchableStrings = [
messages.title,
];
const PaymentInfo: React.FC<Props> = () => {
const dispatch = useDispatch();
const {customer: customerError} = useSelector(getCloudErrors);
const isCardAboutToExpire = useSelector((state: GlobalState) => {
const {customer} = state.entities.cloud;
if (!customer) {
return false;
}
const expiryYear = customer.payment_method.exp_year;
// If not expiry year, or its 0, it's not expired (because it probably isn't set)
if (!expiryYear) {
return false;
}
// This works because we store the expiry month as the actual 1-12 base month, but Date uses a 0-11 base month
// But credit cards expire at the end of their expiry month, so we can just use that number.
const lastExpiryDate = new Date(expiryYear, customer.payment_method.exp_month, 1);
const currentDatePlus10Days = new Date();
currentDatePlus10Days.setDate(currentDatePlus10Days.getDate() + 10);
return lastExpiryDate <= currentDatePlus10Days;
});
const [showCreditCardBanner, setShowCreditCardBanner] = useState(true);
useEffect(() => {
dispatch(getCloudCustomer());
pageVisited('cloud_admin', 'pageview_billing_payment_info');
}, []);
return (
<div className='wrapper--fixed PaymentInfo'>
<AdminHeader>
<FormattedMessage {...messages.title}/>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{showCreditCardBanner && isCardAboutToExpire && (
<AlertBanner
mode='info'
title={
<FormattedMessage
id='admin.billing.payment_info.creditCardAboutToExpire'
defaultMessage='Your credit card is about to expire'
/>
}
message={
<FormattedMessage
id='admin.billing.payment_info.creditCardAboutToExpire.description'
defaultMessage='Please update your payment information to avoid any disruption.'
/>
}
onDismiss={() => setShowCreditCardBanner(false)}
/>
)}
{customerError ? <CloudFetchError/> : <PaymentInfoDisplay/>}
</div>
</div>
</div>
);
};
export default PaymentInfo;

View File

@ -1,149 +0,0 @@
.PaymentInfoDisplay {
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
border-radius: 4px;
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
color: var(--sys-center-channel-color);
}
.PaymentInfoDisplay__header {
display: flex;
align-items: center;
padding: 28px 32px 24px 32px;
border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
}
.PaymentInfoDisplay__headerText-top {
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.PaymentInfoDisplay__headerText-bottom {
font-size: 14px;
line-height: 20px;
}
.PaymentInfoDisplay__addInfo {
margin-left: auto;
}
.PaymentInfoDisplay__addInfoButton {
display: flex;
align-items: center;
padding: 8px 16px;
border-radius: 4px;
background: var(--sys-button-bg);
color: var(--sys-button-color);
&:hover:not(.disabled) {
background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.16), rgba(var(--sys-center-channel-color-rgb), 0.16)), var(--sys-button-bg);
color: var(--sys-button-color);
text-decoration: none;
}
&:active {
background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.32), rgba(var(--sys-center-channel-color-rgb), 0.32)), var(--sys-button-bg);
color: var(--sys-button-color);
text-decoration: none;
}
&:focus {
box-shadow: inset 0 0 0 2px var(--sys-sidebar-text-active-border);
color: var(--sys-button-color);
text-decoration: none;
}
&.disabled {
background: rgba(var(--sys-center-channel-color-rgb), 0.08);
color: rgba(var(--sys-center-channel-color-rgb), 0.32);
}
> i {
font-size: 14.4px;
line-height: 17px;
&::before {
margin: 0;
}
}
> span {
margin-left: 4px;
font-size: 12px;
font-weight: 600;
line-height: 9px;
}
}
.PaymentInfoDisplay__noPaymentInfo {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px;
}
.PaymentInfoDisplay__noPaymentInfo-message {
margin-top: 24px;
color: var(--sys-center-channel-color);
font-size: 14px;
line-height: 20px;
}
.PaymentInfoDisplay__noPaymentInfo-link {
margin-top: 20px;
margin-bottom: 12px;
color: var(--sys-button-bg);
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
.PaymentInfoDisplay__paymentInfo {
display: flex;
padding: 28px 45px 30px 32px;
color: var(--sys-center-channel-color);
font-size: 14px;
line-height: 20px;
.CardImage {
max-width: 55px;
max-height: 37px;
}
}
.PaymentInfoDisplay__paymentInfo-name {
font-weight: 600;
}
.PaymentInfoDisplay__paymentInfo-addressTitle {
margin-top: 16px;
font-weight: 600;
}
.PaymentInfoDisplay__paymentInfo-address > div {
margin-top: 4px;
}
.PaymentInfoDisplay__paymentInfo-edit {
margin-left: auto;
}
.PaymentInfoDisplay__paymentInfo-editButton {
padding: 2px;
border-radius: 4px;
margin-left: 12px;
color: rgba(var(--sys-center-channel-color-rgb), 0.75);
font-size: 18px;
&:hover,
&:focus {
background: rgba(var(--sys-center-channel-color-rgb), 0.08);
color: rgba(var(--sys-center-channel-color-rgb), 0.75);
text-decoration: none;
}
}
.PaymentInfoDisplay__paymentInfo-cardInfo::first-letter {
text-transform: capitalize;
}

View File

@ -1,123 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import BlockableLink from 'components/admin_console/blockable_link';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import type {GlobalState} from 'types/store';
import PaymentDetails from './payment_details';
import './payment_info_display.scss';
const addInfoButton = (
<div className='PaymentInfoDisplay__addInfo'>
<BlockableLink
to='/admin_console/billing/payment_info_edit'
className='PaymentInfoDisplay__addInfoButton'
onClick={() => trackEvent('cloud_admin', 'click_add_credit_card')}
>
<i className='icon icon-plus'/>
<FormattedMessage
id='admin.billing.payment_info.add'
defaultMessage='Add a Credit Card'
/>
</BlockableLink>
</div>
);
const noPaymentInfoSection = (
<div className='PaymentInfoDisplay__noPaymentInfo'>
<CreditCardSvg
width={280}
height={190}
/>
<div className='PaymentInfoDisplay__noPaymentInfo-message'>
<FormattedMessage
id='admin.billing.payment_info_display.noPaymentInfo'
defaultMessage='There are currently no credit cards on file.'
/>
</div>
<BlockableLink
to='/admin_console/billing/payment_info_edit'
className='PaymentInfoDisplay__noPaymentInfo-link'
onClick={() => trackEvent('cloud_admin', 'click_add_credit_card')}
>
<FormattedMessage
id='admin.billing.payment_info.add'
defaultMessage='Add a Credit Card'
/>
</BlockableLink>
</div>
);
const PaymentInfoDisplay: React.FC = () => {
const paymentInfo = useSelector((state: GlobalState) => state.entities.cloud.customer);
const subscription = useGetSubscription();
const openPurchaseModal = useOpenCloudPurchaseModal({});
if (!paymentInfo || !subscription) {
return null;
}
let body = noPaymentInfoSection;
if (paymentInfo?.payment_method && paymentInfo?.billing_address) {
body = (
<div className='PaymentInfoDisplay__paymentInfo'>
<PaymentDetails/>
<div className='PaymentInfoDisplay__paymentInfo-edit'>
{subscription.delinquent_since ? (
<div
className='PaymentInfoDisplay__paymentInfo-editButton'
onClick={() => openPurchaseModal({trackingLocation: 'edit_payment_info'})}
>
<i className='icon icon-pencil-outline'/>
</div>
) : (
<BlockableLink
to='/admin_console/billing/payment_info_edit'
className='PaymentInfoDisplay__paymentInfo-editButton'
>
<i className='icon icon-pencil-outline'/>
</BlockableLink>
)}
</div>
</div>
);
}
return (
<div className='PaymentInfoDisplay'>
<div className='PaymentInfoDisplay__header'>
<div className='PaymentInfoDisplay__headerText'>
<div className='PaymentInfoDisplay__headerText-top'>
<FormattedMessage
id='admin.billing.payment_info_display.savedPaymentDetails'
defaultMessage='Your saved payment details'
/>
</div>
<div className='PaymentInfoDisplay__headerText-bottom'>
<FormattedMessage
id='admin.billing.payment_info_display.allCardsAccepted'
defaultMessage='All major credit cards are accepted.'
/>
</div>
</div>
{!(paymentInfo?.payment_method && paymentInfo?.billing_address) && addInfoButton}
</div>
<div className='PaymentInfoDisplay__body'>
{body}
</div>
</div>
);
};
export default PaymentInfoDisplay;

View File

@ -1,57 +0,0 @@
.PaymentInfoEdit__card {
padding: 28px 32px;
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
border-radius: 4px;
margin-top: 20px;
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
}
.PaymentInfoEdit__paymentForm {
max-width: 480px;
padding: 0;
margin: 0;
.section-title:not(:first-child) {
font-size: 14px;
line-height: 20px;
}
.section-title {
color: var(--sys-center-channel-text);
}
.Input_fieldset {
background: var(--sys-center-channel-bg);
}
&.Input_fieldset:focus-within {
box-shadow: inset 0 0 0 2px var(--sys-button-bg);
}
&.Input_fieldset___error {
box-shadow: inset 0 0 0 1px var(--sys-error-text);
color: var(--sys-error-text);
}
&.Input_fieldset___error:focus-within {
box-shadow: inset 0 0 0 2px var(--sys-error-text);
color: var(--sys-error-text);
}
.Input::placeholder {
color: var(--sys-center-channel-color);
}
}
.PaymentInfoEdit__error {
display: flex;
align-items: center;
color: var(--sys-error-text);
font-size: 12px;
i {
margin-right: 4px;
font-size: 14.4px;
}
}

View File

@ -1,194 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Elements} from '@stripe/react-stripe-js';
import type {Stripe} from '@stripe/stripe-js';
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
import React, {useEffect, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useHistory} from 'react-router-dom';
import {getCloudCustomer} from 'mattermost-redux/actions/cloud';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {completeStripeAddPaymentMethod} from 'actions/cloud';
import {isCwsMockMode} from 'selectors/cloud';
import BlockableLink from 'components/admin_console/blockable_link';
import AlertBanner from 'components/alert_banner';
import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import PaymentForm from 'components/payment_form/payment_form';
import {STRIPE_CSS_SRC, getStripePublicKey} from 'components/payment_form/stripe';
import SaveButton from 'components/save_button';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {CloudLinks} from 'utils/constants';
import {areBillingDetailsValid} from 'types/cloud/sku';
import type {BillingDetails} from 'types/cloud/sku';
import type {GlobalState} from 'types/store';
import './payment_info_edit.scss';
let stripePromise: Promise<Stripe | null>;
const PaymentInfoEdit: React.FC = () => {
const dispatch = useDispatch();
const history = useHistory();
const cwsMockMode = useSelector(isCwsMockMode);
const paymentInfo = useSelector((state: GlobalState) => state.entities.cloud.customer);
const theme = useSelector(getTheme);
const [showCreditCardWarning, setShowCreditCardWarning] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
const [isServerError, setIsServerError] = useState(false);
const [billingDetails, setBillingDetails] = useState<BillingDetails>({
address: paymentInfo?.billing_address?.line1 || '',
address2: paymentInfo?.billing_address?.line2 || '',
city: paymentInfo?.billing_address?.city || '',
state: paymentInfo?.billing_address?.state || '',
country: paymentInfo?.billing_address?.country || '',
postalCode: paymentInfo?.billing_address?.postal_code || '',
name: '',
card: {} as any,
});
const stripePublicKey = useSelector((state: GlobalState) => getStripePublicKey(state));
useEffect(() => {
dispatch(getCloudCustomer());
}, []);
const onPaymentInput = (billing: BillingDetails) => {
setIsServerError(false);
setIsValid(areBillingDetailsValid(billing));
setBillingDetails(billing);
};
const handleSubmit = async () => {
setIsSaving(true);
const setPaymentMethod = completeStripeAddPaymentMethod((await stripePromise)!, billingDetails!, cwsMockMode);
const success = await setPaymentMethod();
if (success) {
history.push('/admin_console/billing/payment_info');
} else {
setIsServerError(true);
}
setIsSaving(false);
};
if (!stripePromise) {
stripePromise = loadStripe(stripePublicKey);
}
return (
<div className='wrapper--fixed PaymentInfoEdit'>
<AdminHeader withBackButton={true}>
<div>
<BlockableLink
to='/admin_console/billing/payment_info'
className='fa fa-angle-left back'
/>
<FormattedMessage
id='admin.billing.payment_info_edit.title'
defaultMessage='Edit Payment Information'
/>
</div>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{showCreditCardWarning &&
<AlertBanner
mode='info'
title={
<FormattedMessage
id='admin.billing.payment_info_edit.creditCardWarningTitle'
defaultMessage='NOTE: Your card will not be charged at this time'
/>
}
message={
<>
<FormattedMarkdownMessage
id='admin.billing.payment_info_edit.creditCardWarningDescription'
defaultMessage='Your credit card will be charged based on the number of users you have at the end of the monthly billing cycle. '
/>
<ExternalLink
location='payment_info_edit'
href={CloudLinks.BILLING_DOCS}
>
<FormattedMessage
id='admin.billing.subscription.planDetails.howBillingWorks'
defaultMessage='See how billing works'
/>
</ExternalLink>
</>
}
onDismiss={() => setShowCreditCardWarning(false)}
/>
}
<div className='PaymentInfoEdit__card'>
<Elements
options={{fonts: [{cssSrc: STRIPE_CSS_SRC}]}}
stripe={stripePromise}
>
<PaymentForm
className='PaymentInfoEdit__paymentForm'
onInputChange={onPaymentInput}
initialBillingDetails={billingDetails}
theme={theme}
/>
</Elements>
</div>
</div>
</div>
<div className='admin-console-save'>
<SaveButton
saving={isSaving}
disabled={!billingDetails || !isValid}
onClick={handleSubmit}
defaultMessage={(
<FormattedMessage
id='admin.billing.payment_info_edit.save'
defaultMessage='Save credit card'
/>
)}
/>
<BlockableLink
className='cancel-button'
to='/admin_console/billing/payment_info'
>
<FormattedMessage
id='admin.billing.payment_info_edit.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
{isValid === false &&
<span className='PaymentInfoEdit__error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='admin.billing.payment_info_edit.formError'
defaultMessage='There are errors in the form above'
/>
</span>
}
{isServerError &&
<span className='PaymentInfoEdit__error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='admin.billing.payment_info_edit.serverError'
defaultMessage='Something went wrong while saving payment infomation'
/>
</span>
}
</div>
</div>
);
};
export default PaymentInfoEdit;

View File

@ -12,17 +12,15 @@ import {trackEvent} from 'actions/telemetry_actions';
import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner';
import AlertBanner from 'components/alert_banner';
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
import PurchaseModal from 'components/purchase_modal';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
import {ModalIdentifiers, TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
import {TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import * as Utils from 'utils/utils';
@ -82,23 +80,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
this.props.actions.getPrevTrialLicense();
}
openUpgradeModal = (e: React.MouseEvent) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_subscribe_from_feature_discovery',
);
this.props.actions.openModal({
modalId: ModalIdentifiers.CLOUD_PURCHASE,
dialogType: PurchaseModal,
dialogProps: {
callerCTA: 'feature_discovery_subscribe_button',
},
});
};
contactSalesFunc = () => {
const {customer, isCloud} = this.props;
const customerEmail = customer?.email || '';
@ -157,9 +138,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
/>
}
/>
<ContactUsButton
eventID='post_trial_contact_sales'
/>
</>
</div>
@ -172,7 +150,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
isCloudTrial,
hadPrevCloudTrial,
isPaidSubscription,
minimumSKURequiredForFeature,
} = this.props;
const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription;
@ -217,42 +194,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
</button>
);
}
} else if (hadPrevCloudTrial) {
// if it is cloud, but this account already had a free trial, then the cta button must be Upgrade now
ctaPrimaryButton = (
<button
className='btn btn-primary'
data-testid='featureDiscovery_primaryCallToAction'
onClick={this.openUpgradeModal}
>
<FormattedMessage
id='admin.ldap_feature_discovery_cloud.call_to_action.primary'
defaultMessage='Upgrade now'
/>
</button>
);
if (minimumSKURequiredForFeature === LicenseSkus.Enterprise) {
ctaPrimaryButton = (
<button
className='btn btn-primary'
data-testid='featureDiscovery_primaryCallToAction'
onClick={() => {
if (isCloud) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
} else {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
}
this.contactSalesFunc();
}}
>
<FormattedMessage
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
defaultMessage='Contact sales'
/>
</button>
);
}
}
}

View File

@ -5,23 +5,16 @@ import classNames from 'classnames';
import React, {useEffect, useState} from 'react';
import type {RefObject} from 'react';
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, defineMessages, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import type {ClientLicense} from '@mattermost/types/config';
import {Client4} from 'mattermost-redux/client';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {trackEvent} from 'actions/telemetry_actions';
import {getExpandSeatsLink} from 'selectors/cloud';
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import Tag from 'components/widgets/tag/tag';
import {FileTypes, TELEMETRY_CATEGORIES} from 'utils/constants';
import {useQuery} from 'utils/http_utils';
import {FileTypes} from 'utils/constants';
import {calculateOverageUserActivated} from 'utils/overage_team';
import {getSkuDisplayName} from 'utils/subscription';
import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils';
@ -63,20 +56,7 @@ const EnterpriseEditionLeftPanel = ({
const {formatMessage} = useIntl();
const [unsanitizedLicense, setUnsanitizedLicense] = useState(license);
const openPricingModal = useOpenPricingModal();
const canExpand = useCanSelfHostedExpand();
const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'});
const expandableLink = useSelector(getExpandSeatsLink);
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const query = useQuery();
const actionQueryParam = query.get('action');
useEffect(() => {
if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) {
selfHostedExpansionModal.open();
query.set('action', '');
}
}, []);
const [openContactSales] = useOpenSalesLink();
useEffect(() => {
async function fetchUnSanitizedLicense() {
@ -106,15 +86,6 @@ const EnterpriseEditionLeftPanel = ({
</button>
);
const handleClickAddSeats = () => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'add_seats_clicked');
if (!isSelfHostedPurchaseEnabled || !canExpand) {
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
} else {
selfHostedExpansionModal.open();
}
};
return (
<div
className='EnterpriseEditionLeftPanel'
@ -155,17 +126,15 @@ const EnterpriseEditionLeftPanel = ({
<div className='licenseInformation'>
<div className='license-details-top'>
<span className='title'>{'License details'}</span>
{canExpand &&
<button
className='add-seats-button btn btn-primary'
onClick={handleClickAddSeats}
>
<FormattedMessage
id={'admin.license.enterpriseEdition.add.seats'}
defaultMessage='+ Add seats'
/>
</button>
}
<button
className='add-seats-button btn btn-primary'
onClick={openContactSales}
>
<FormattedMessage
id={'admin.license.enterpriseEdition.add.seats'}
defaultMessage='+ Add seats'
/>
</button>
</div>
{
renderLicenseContent(

View File

@ -40,7 +40,7 @@
.purchase_buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: center;
margin-top: 20px;
button {
@ -54,7 +54,7 @@
&.contact-us {
width: 130px;
margin-left: 15px;
margin-left: 0px !important;
}
}

View File

@ -5,7 +5,6 @@ import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg';
const StarterRightPanel = () => {
@ -44,15 +43,6 @@ const StarterRightPanel = () => {
})}
</div>
<div className='purchase_buttons'>
<PurchaseLink
eventID='post_trial_purchase_license'
buttonTextElement={
<FormattedMessage
id='admin.license.trialCard.purchase'
defaultMessage='Purchase'
/>
}
/>
<ContactUsButton
eventID='post_trial_contact_sales'
/>

View File

@ -9,8 +9,6 @@ import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/bill
import PostLimitsAnnouncementBar from 'components/announcement_bar/post_limits_announcement_bar';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import CloudAnnualRenewalAnnouncementBar from './cloud_annual_renewal';
import CloudDelinquencyAnnouncementBar from './cloud_delinquency';
import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar';
import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar';
import ConfigurationAnnouncementBar from './configuration_bar';
@ -68,8 +66,6 @@ class AnnouncementBarController extends React.PureComponent<Props> {
let paymentAnnouncementBar = null;
let cloudTrialAnnouncementBar = null;
let cloudTrialEndAnnouncementBar = null;
let cloudDelinquencyAnnouncementBar = null;
let cloudRenewalAnnouncementBar = null;
const notifyAdminDowngradeDelinquencyBar = null;
const toYearlyNudgeBannerDismissable = null;
let toPaidPlanNudgeBannerDismissable = null;
@ -83,12 +79,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
cloudTrialEndAnnouncementBar = (
<CloudTrialEndAnnouncementBar/>
);
cloudDelinquencyAnnouncementBar = (
<CloudDelinquencyAnnouncementBar/>
);
cloudRenewalAnnouncementBar = (
<CloudAnnualRenewalAnnouncementBar/>
);
toPaidPlanNudgeBannerDismissable = (<ToPaidPlanBannerDismissable/>);
}
@ -122,8 +113,6 @@ class AnnouncementBarController extends React.PureComponent<Props> {
{paymentAnnouncementBar}
{cloudTrialAnnouncementBar}
{cloudTrialEndAnnouncementBar}
{cloudDelinquencyAnnouncementBar}
{cloudRenewalAnnouncementBar}
{notifyAdminDowngradeDelinquencyBar}
{toYearlyNudgeBannerDismissable}
{toPaidPlanNudgeBannerDismissable}

View File

@ -1,374 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {unixTimestampFromNow} from 'tests/helpers/date';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import CloudAnnualRenewalAnnouncementBar, {getCurrentYearAsString} from './index';
describe('components/announcement_bar/cloud_annual_renewal', () => {
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
admin: {
config: {
FeatureFlags: {
CloudAnnualRenewals: true,
},
},
},
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
cancel_at: null,
},
products: {
test_prod_1: {
id: 'test_prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
},
test_prod_2: {
id: 'test_prod_2',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 0,
},
test_prod_3: {
id: 'test_prod_3',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 0,
},
},
},
},
};
it('Should not show banner when feature flag is disabled time set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.general.config = {
...state.entities.admin.config,
CloudAnnualRenewals: false,
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should not show banner when no cancel_at time set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: null,
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 60 day banner to admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
const {getByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription expires in 55 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show 60 day banner to non-admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 30 day banner to admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
const {getByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show 30 day banner to non-admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 7 day banner to admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(5),
};
const {getByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription expires in 5 days. Failure to renew will result in your workspace being deleted.')).toBeInTheDocument();
});
it('Should NOT show 7 day banner to non admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(5),
};
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should NOT show 7 day banner to admin when delinquent_since is set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(5),
delinquent_since: unixTimestampFromNow(5),
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in 6 days. Failure to renew will result in your workspace being deleted.')).not.toBeInTheDocument();
});
it('Should NOT show 60 day banner to admin when they dismissed the banner', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_60_day_2023',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should NOT show 30 day banner to admin when they dismissed the banner', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_30_day_2023',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 60 day banner to admin in 2023 when they dismissed the banner in 2022', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_60_DAY}_2022`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_60_day_2022',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in 55 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should show 30 day banner to admin in 2023 when they dismissed the banner in 2022', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_30_DAY}_2022`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_30_day_2022',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show any banner if renewal date is more than 60 days away"', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(75),
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should NOT show any banner if within renewal period but will_renew is set to "true"', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
will_renew: 'true',
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
});

View File

@ -1,143 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
import {getConfig as adminGetConfig} from 'mattermost-redux/actions/admin';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import {daysToExpiration} from 'utils/cloud_utils';
import {Preferences, AnnouncementBarTypes, CloudBanners} from 'utils/constants';
import type {GlobalState} from 'types/store';
import AnnouncementBar from '../default_announcement_bar';
const between = (x: number, min: number, max: number) => {
return x >= min && x <= max;
};
export const getCurrentYearAsString = () => {
const now = new Date();
const year = now.getFullYear();
return year.toString();
};
const CloudAnnualRenewalAnnouncementBar = () => {
const subscription = useGetSubscription();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const {formatMessage} = useIntl();
const {isDelinquencySubscription} = useDelinquencySubscription();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const dispatch = useDispatch();
const currentUserId = useSelector(getCurrentUserId);
const hasDismissed60DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`)) === 'true';
const hasDismissed30DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`)) === 'true';
const config = useSelector(getConfig);
const cloudAnnualRenewalsEnabled = config.FeatureFlags?.CloudAnnualRenewals;
useEffect(() => {
if (!config || !config.FeatureFlags) {
dispatch(adminGetConfig());
}
}, []);
const daysUntilExpiration = useMemo(() => {
if (!subscription || !subscription.end_at || !subscription.cancel_at) {
return 0;
}
return daysToExpiration(subscription);
}, [subscription]);
const handleDismiss = (banner: string) => {
// We store the preference name with the current year as a string appended to the end,
// so that next renewal period we can show the banner again despite the user having dismissed it in the previous year
dispatch(savePreferences(currentUserId, [{
category: Preferences.CLOUD_ANNUAL_RENEWAL_BANNER,
name: `${banner}_${getCurrentYearAsString()}`,
user_id: currentUserId,
value: 'true',
}]));
};
const getBanner = useMemo(() => {
const defaultProps = {
showLinkAsButton: true,
isTallBanner: true,
icon: <i className='icon icon-alert-outline'/>,
modalButtonText: formatMessage({id: 'cloud_annual_renewal.banner.buttonText.renew', defaultMessage: 'Renew'}),
modalButtonDefaultText: 'Renew',
message: <></>,
onButtonClick: openPurchaseModal,
handleClose: () => { },
showCloseButton: true,
};
let bannerProps = {
...defaultProps,
type: '',
};
if (between(daysUntilExpiration, 31, 60)) {
if (hasDismissed60DayBanner) {
return null;
}
bannerProps = {
...defaultProps,
message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.60', defaultMessage: 'Your annual subscription expires in {days} days. Please renew to avoid any disruption.'}, {days: daysUntilExpiration})}</>),
icon: (<InformationOutlineIcon size={18}/>),
type: AnnouncementBarTypes.ANNOUNCEMENT,
handleClose: () => handleDismiss(CloudBanners.ANNUAL_RENEWAL_60_DAY),
};
} else if (between(daysUntilExpiration, 8, 30)) {
if (hasDismissed30DayBanner) {
return null;
}
bannerProps = {
...defaultProps,
message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.30', defaultMessage: 'Your annual subscription expires in {days} days. Please renew to avoid any disruption.'}, {days: daysUntilExpiration})}</>),
icon: (<InformationOutlineIcon size={18}/>),
type: AnnouncementBarTypes.ADVISOR,
handleClose: () => handleDismiss(CloudBanners.ANNUAL_RENEWAL_30_DAY),
};
} else if (between(daysUntilExpiration, 0, 7) && !isDelinquencySubscription()) {
// This banner is not dismissable
bannerProps = {
...defaultProps,
message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.7', defaultMessage: 'Your annual subscription expires in {days} days. Failure to renew will result in your workspace being deleted.'}, {days: daysUntilExpiration})}</>),
icon: (<i className='icon icon-alert-outline'/>),
type: AnnouncementBarTypes.CRITICAL,
showCloseButton: false,
};
} else {
// If none of the above, return null, so that a blank announcement bar isn't visible
return null;
}
return <AnnouncementBar {...bannerProps}/>;
}, [daysUntilExpiration, hasDismissed60DayBanner, hasDismissed30DayBanner]);
// Delinquent subscriptions will have a cancel_at time, but the banner is handled separately
if (!cloudAnnualRenewalsEnabled || !subscription || !subscription.cancel_at || subscription.is_free_trial === 'true' || subscription.will_renew === 'true' || isDelinquencySubscription() || !isAdmin || daysUntilExpiration > 60) {
return null;
}
return (
<>
{getBanner}
</>
);
};
export default CloudAnnualRenewalAnnouncementBar;

View File

@ -1,124 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import CloudDelinquencyAnnouncementBar from './index';
describe('components/announcement_bar/cloud_delinquency', () => {
const now = new Date();
const fiveDaysAgo = new Date(now.getTime() - (5 * 24 * 60 * 60 * 1000)).getTime() / 1000;
const fiveDaysFromNow = new Date(now.getTime() + (5 * 24 * 60 * 60 * 1000)).getTime() / 1000;
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
delinquent_since: fiveDaysAgo, // may 17 2022
cancel_at: fiveDaysFromNow, // may 17 2022
},
products: {
test_prod_1: {
id: 'test_prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
},
test_prod_2: {
id: 'test_prod_2',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 0,
},
test_prod_3: {
id: 'test_prod_3',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 0,
},
},
},
},
};
it('Should not show banner when not delinquent', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
delinquent_since: null,
};
const {queryByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription has expired')).not.toBeInTheDocument();
});
it('Should not show banner when no cancel_at time is set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: null,
};
const {queryByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription has expired')).not.toBeInTheDocument();
});
it('Should show banner when user is not admin, but should not show CTA', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText, getByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription has expired. Please contact your System Admin to keep this workspace')).toBeInTheDocument();
expect(queryByText('Update billing now')).not.toBeInTheDocument();
});
it('Should show banner and CTA when user is admin', () => {
const state = JSON.parse(JSON.stringify(initialState));
const {getByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription has expired. Please renew now to keep this workspace')).toBeInTheDocument();
expect(getByText('Update billing now')).toBeInTheDocument();
});
});

View File

@ -1,67 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {trackEvent} from 'actions/telemetry_actions';
import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import {
AnnouncementBarTypes, TELEMETRY_CATEGORIES,
} from 'utils/constants';
import AnnouncementBar from '../default_announcement_bar';
const CloudDelinquencyAnnouncementBar = () => {
const subscription = useGetSubscription();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const {isDelinquencySubscription} = useDelinquencySubscription();
const {formatMessage} = useIntl();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
if (!isDelinquencySubscription() || !subscription?.cancel_at) {
return null;
}
let props = {
message: (<>{formatMessage({id: 'cloud_annual_renewal_delinquency.banner.message', defaultMessage: 'Your annual subscription has expired. Please renew now to keep this workspace'})}</>),
modalButtonText: formatMessage({id: 'cloud_delinquency.banner.buttonText', defaultMessage: 'Update billing now'}),
modalButtonDefaultText: 'Update billing now',
showLinkAsButton: true,
isTallBanner: true,
icon: <i className='icon icon-alert-outline'/>,
showCTA: true,
onButtonClick: () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_update_billing');
openPurchaseModal({
trackingLocation:
'cloud_delinquency_announcement_bar',
});
},
type: AnnouncementBarTypes.CRITICAL,
showCloseButton: false,
};
if (!isAdmin) {
props = {
...props,
message: (<>{formatMessage({id: 'cloud_annual_renewal_delinquency.banner.end_user.message', defaultMessage: 'Your annual subscription has expired. Please contact your System Admin to keep this workspace'})}</>),
showCTA: false,
};
}
return (
<AnnouncementBar
{...props}
/>
);
};
export default CloudDelinquencyAnnouncementBar;

View File

@ -2,22 +2,10 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {trackEvent} from 'actions/telemetry_actions';
import useCanSelfHostedSignup from 'components/common/hooks/useCanSelfHostedSignup';
import {
useControlAirGappedSelfHostedPurchaseModal,
useControlScreeningInProgressModal,
} from 'components/common/hooks/useControlModal';
import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal';
import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts';
import {CloudLinks, SelfHostedProducts} from 'utils/constants';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import './purchase_link.scss';
@ -27,35 +15,13 @@ export interface Props {
}
const PurchaseLink: React.FC<Props> = (props: Props) => {
const controlAirgappedModal = useControlAirGappedSelfHostedPurchaseModal();
const controlScreeningInProgressModal = useControlScreeningInProgressModal();
const selfHostedSignupAvailable = useCanSelfHostedSignup();
const [products, productsLoaded] = useGetSelfHostedProducts();
const professionalProductId = findSelfHostedProductBySku(products, SelfHostedProducts.PROFESSIONAL)?.id || '';
const controlSelfHostedPurchaseModal = useControlSelfHostedPurchaseModal({productId: professionalProductId});
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const [openSalesLink] = useOpenSalesLink();
const handlePurchaseLinkClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
trackEvent('admin', props.eventID || 'in_trial_purchase_license');
if (!isSelfHostedPurchaseEnabled) {
window.open(CloudLinks.SELF_HOSTED_SIGNUP, '_blank');
return;
}
if (!selfHostedSignupAvailable.ok) {
if (selfHostedSignupAvailable.screeningInProgress) {
controlScreeningInProgressModal.open();
} else {
controlAirgappedModal.open();
}
return;
}
if (productsLoaded && professionalProductId) {
controlSelfHostedPurchaseModal.open();
}
openSalesLink();
};
return (

View File

@ -1,91 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import PurchaseInProgressModal from 'components/purchase_in_progress_modal';
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants';
import SelfHostedExpansionModal from 'components/self_hosted_purchases/self_hosted_expansion_modal';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {useControlModal} from './useControlModal';
import type {ControlModal} from './useControlModal';
interface HookOptions{
trackingLocation?: string;
}
export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal {
const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
const controlModal = useControlModal({
modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION,
dialogType: SelfHostedExpansionModal,
});
return useMemo(() => {
return {
...controlModal,
open: async () => {
const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true';
// check if user already has an open purchase modal in current browser.
if (purchaseInProgress) {
// User within the same browser session
// is already trying to purchase. Notify them of this
// and request the exit that purchase flow before attempting again.
dispatch(openModal({
modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS,
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: currentUser.email,
storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS,
},
}));
return;
}
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'click_open_expansion_modal', {
callerInfo: options.trackingLocation,
});
try {
const result = await Client4.bootstrapSelfHostedSignup();
if (result.email !== currentUser.email) {
// Token already exists and was created by another admin.
// Notify user of this and do not allow them to try to expand concurrently.
dispatch(openModal({
modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS,
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: result.email,
storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS,
},
}));
return;
}
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: result.progress,
});
controlModal.open();
} catch (e) {
// eslint-disable-next-line no-console
console.error('error bootstrapping self hosted purchase modal', e);
}
},
};
}, [controlModal, options.trackingLocation]);
}

View File

@ -1,114 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {trackEvent} from 'actions/telemetry_actions';
import {closeModal, openModal} from 'actions/views/modals';
import {isModalOpen} from 'selectors/views/modals';
import PurchaseInProgressModal from 'components/purchase_in_progress_modal';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchases/constants';
import SelfHostedPurchaseModal from 'components/self_hosted_purchases/self_hosted_purchase_modal';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import type {GlobalState} from 'types/store';
import {useControlModal} from './useControlModal';
import type {ControlModal} from './useControlModal';
interface HookOptions{
onClick?: () => void;
productId: string;
trackingLocation?: string;
}
export default function useControlSelfHostedPurchaseModal(options: HookOptions): ControlModal {
const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
const controlModal = useControlModal({
modalId: ModalIdentifiers.SELF_HOSTED_PURCHASE,
dialogType: SelfHostedPurchaseModal,
dialogProps: {
productId: options.productId,
},
});
const pricingModalOpen = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.PRICING_MODAL));
const purchaseModalOpen = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.SELF_HOSTED_PURCHASE));
const comparingPlansWhilePurchasing = pricingModalOpen && purchaseModalOpen;
return useMemo(() => {
return {
...controlModal,
open: async () => {
// check if purchase modal is already open
// i.e. they are allowed to compare plans from within the purchase modal
// if so, all we need to do is close the compare plans modal so that
// the purchase modal is available again.
if (comparingPlansWhilePurchasing) {
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
return;
}
const purchaseInProgress = localStorage.getItem(STORAGE_KEY_PURCHASE_IN_PROGRESS) === 'true';
// check if user already has an open purchase modal in current browser.
if (purchaseInProgress) {
// User within the same browser session
// is already trying to purchase. Notify them of this
// and request the exit that purchase flow before attempting again.
dispatch(openModal({
modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS,
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: currentUser.email,
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
},
}));
return;
}
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING, 'click_open_purchase_modal', {
callerInfo: options.trackingLocation,
});
if (options.onClick) {
options.onClick();
}
try {
const result = await Client4.bootstrapSelfHostedSignup();
if (result.email !== currentUser.email) {
// JWT already exists and was created by another admin,
// meaning another admin is already trying to purchase.
// Notify user of this and do not allow them to try to purchase concurrently.
dispatch(openModal({
modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS,
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: result.email,
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
},
}));
return;
}
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: result.progress,
});
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
controlModal.open();
} catch (e) {
// eslint-disable-next-line no-console
console.error('error bootstrapping self hosted purchase modal', e);
}
},
};
}, [controlModal, options.productId, options.onClick, options.trackingLocation, comparingPlansWhilePurchasing]);
}

View File

@ -1,37 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useDispatch} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import PurchaseModal from 'components/purchase_modal';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
interface OpenPurchaseModalOptions{
onClick?: () => void;
trackingLocation?: string;
isDelinquencyModal?: boolean;
}
type TelemetryProps = Pick<OpenPurchaseModalOptions, 'trackingLocation'>
export default function useOpenCloudPurchaseModal(options: OpenPurchaseModalOptions) {
const dispatch = useDispatch();
return (telemetryProps: TelemetryProps) => {
if (options.onClick) {
options.onClick();
}
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, options.isDelinquencyModal ? 'click_open_delinquency_modal' : 'click_open_purchase_modal', {
callerInfo: telemetryProps.trackingLocation,
});
dispatch(openModal({
modalId: ModalIdentifiers.CLOUD_PURCHASE,
dialogType: PurchaseModal,
dialogProps: {
callerCTA: telemetryProps.trackingLocation,
},
}));
};
}

View File

@ -1,293 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
import {getUsers} from 'mattermost-redux/selectors/entities/users';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import LearnMoreTrialModal from 'components/learn_more_trial_modal/learn_more_trial_modal';
import Markdown from 'components/markdown';
import {ModalIdentifiers, MattermostFeatures} from 'utils/constants';
import {mapFeatureIdToTranslation} from 'utils/notify_admin_utils';
const MinimumPlansForFeature = {
Professional: 'Professional plan',
Enterprise: 'Enterprise plan',
};
type FeatureRequest = {
user_id: string;
required_feature: string;
required_plan: string;
create_at: string;
trial: string;
}
type RequestedFeature = Record<string, FeatureRequest[]>
type CustomPostProps = {
requested_features: RequestedFeature;
trial: boolean;
}
const style = {
display: 'flex',
gap: '10px',
padding: '12px',
borderRadius: '4px',
border: '1px solid rgba(var(--center-channel-color-rgb), 0.16)',
width: 'max-content',
margin: '10px 0',
};
const btnStyle = {
background: 'var(--button-bg)',
color: 'var(--button-color)',
border: 'none',
borderRadius: '4px',
padding: '8px 20px',
fontWeight: 600,
};
const messageStyle = {
marginBottom: '16px',
};
export default function OpenPricingModalPost(props: {post: Post}) {
let allProfessional = true;
const dispatch = useDispatch();
const userProfiles = useSelector(getUsers);
const openPurchaseModal = useOpenCloudPurchaseModal({});
const {formatMessage} = useIntl();
const openPricingModal = useOpenPricingModal();
const getUserIdsForUsersThatRequestedFeature = (requests: FeatureRequest[]): string[] => requests.map((request: FeatureRequest) => request.user_id);
const postProps = props.post.props as Partial<CustomPostProps>;
const requestFeatures = postProps?.requested_features;
const wasTrialRequest = postProps?.trial;
useEffect(() => {
if (requestFeatures) {
for (const featureId of Object.keys(requestFeatures)) {
dispatch(getMissingProfilesByIds(getUserIdsForUsersThatRequestedFeature(requestFeatures[featureId])));
}
}
}, [dispatch, requestFeatures]);
const isDowngradeNotification = (featureId: string) => featureId === MattermostFeatures.UPGRADE_DOWNGRADED_WORKSPACE;
const customMessageBody = [];
const getUserNamesForUsersThatRequestedFeature = (requests: FeatureRequest[]): string[] => {
const unknownName = formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.unknown', defaultMessage: '@unknown'});
const userNames = requests.map((req: FeatureRequest) => {
const username = userProfiles[req.user_id]?.username;
return username ? '@' + username : unknownName;
});
return userNames;
};
const renderUsersThatRequestedFeature = (requests: FeatureRequest[]) => {
if (requests.length >= 5) {
return formatMessage({
id: 'postypes.custom_open_pricing_modal_post_renderer.members',
defaultMessage: '{members} members'},
{members: requests.length});
}
let renderedUsers;
const users = getUserNamesForUsersThatRequestedFeature(requests);
if (users.length === 1) {
renderedUsers = users[0];
} else {
const lastUser = users.splice(-1, 1)[0];
users.push(formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.and', defaultMessage: 'and'}) + ' ' + lastUser);
renderedUsers = users.join(', ').replace(/,([^,]*)$/, '$1');
}
return renderedUsers;
};
const markDownOptions = {
atSumOfMembersMentions: true,
atPlanMentions: true,
markdown: false,
};
const mapFeatureToPlan = (feature: string) => {
switch (feature) {
case MattermostFeatures.ALL_ENTERPRISE_FEATURES:
case MattermostFeatures.CUSTOM_USER_GROUPS:
allProfessional = false;
return MinimumPlansForFeature.Enterprise;
default:
return MinimumPlansForFeature.Professional;
}
};
if (requestFeatures) {
for (const featureId of Object.keys(requestFeatures)) {
let title = (
<div id={`${featureId}-title`.replaceAll('.', '_')}>
<span>
<b>
{mapFeatureIdToTranslation(featureId, formatMessage)}
</b>
</span>
<span>
<Markdown
message={formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.availableOn', defaultMessage: ' - available on the {feature}'}, {feature: mapFeatureToPlan(featureId)})}
options={{...markDownOptions, atSumOfMembersMentions: false}}
/>
</span>
</div>);
let subTitle = (
<ul id={`${featureId}-subtitle`.replaceAll('.', '_')}>
<li>
<Markdown
postId={props.post.id}
message={formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.userRequests', defaultMessage: '{userRequests} requested access to this feature'}, {userRequests: renderUsersThatRequestedFeature(requestFeatures[featureId])})}
options={markDownOptions}
userIds={getUserIdsForUsersThatRequestedFeature(requestFeatures[featureId])}
messageMetadata={{requestedFeature: featureId}}
/>
</li>
</ul>);
if (isDowngradeNotification(featureId)) {
title = (
<div id={`${featureId}-title`.replaceAll('.', '_')}>
<span>
<b>
{mapFeatureIdToTranslation(featureId, formatMessage)}
</b>
</span>
</div>);
subTitle = (
<ul id={`${featureId}-subtitle`.replaceAll('.', '_')}>
<li>
<Markdown
postId={props.post.id}
message={formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.downgradeNotfication', defaultMessage: '{userRequests} requested to revert the workspace to a paid plan'}, {userRequests: renderUsersThatRequestedFeature(requestFeatures[featureId])})}
options={markDownOptions}
userIds={getUserIdsForUsersThatRequestedFeature(requestFeatures[featureId])}
messageMetadata={{requestedFeature: featureId}}
/>
</li>
</ul>);
}
const featureMessage = (
<div style={messageStyle}>
{title}
{subTitle}
</div>
);
customMessageBody.push(featureMessage);
}
}
const openLearnMoreTrialModal = () => {
dispatch(openModal({
modalId: ModalIdentifiers.LEARN_MORE_TRIAL_MODAL,
dialogType: LearnMoreTrialModal,
dialogProps: {
launchedBy: 'pricing_modal',
},
}));
};
const renderButtons = () => {
if (wasTrialRequest) {
return (
<>
<button
id='learn_more_about_trial'
onClick={() => {
trackEvent('cloud_admin', 'click_learn_more_trial_modal', {
callerInfo: 'notify_admin_learn_more_about_trial',
});
openLearnMoreTrialModal();
}}
style={btnStyle}
>
{formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.learn_trial', defaultMessage: 'Learn more about trial'})}
</button>
<button
onClick={() => openPricingModal({trackingLocation: 'notify_admin_message_view_upgrade_options'})}
style={{...btnStyle, color: 'var(--button-bg)', background: 'rgba(var(--denim-button-bg-rgb), 0.08)'}}
>
{formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.view_options', defaultMessage: 'View upgrade options'})}
</button>
</>
);
}
if (allProfessional) {
return (
<>
<button
id='upgrade_to_professional'
onClick={() => openPurchaseModal({trackingLocation: 'notify_admin_message_view'})}
style={btnStyle}
>
{formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.upgrade_professional', defaultMessage: 'Upgrade to Professional'})}
</button>
<button
id='view_upgrade_options'
onClick={() => openPricingModal({trackingLocation: 'notify_admin_message_view_upgrade_options'})}
style={{...btnStyle, color: 'var(--button-bg)', background: 'rgba(var(--denim-button-bg-rgb), 0.08)'}}
>
{formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.view_options', defaultMessage: 'View upgrade options'})}
</button>
</>
);
}
return (
<button
id='view_upgrade_options'
onClick={() => openPricingModal({trackingLocation: 'notify_admin_message_view_upgrade_options'})}
style={{...btnStyle, border: '1px solid var(--button-bg)', color: 'var(--button-bg)', background: 'var(--sidebar-text)'}}
>
{formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.view_options', defaultMessage: 'View upgrade options'})}
</button>
);
};
return (
<div>
<div style={messageStyle}>
<Markdown
message={props.post.message}
options={{...markDownOptions, atSumOfMembersMentions: false}}
/>
</div>
{customMessageBody}
<div style={{display: 'flex'}}>
<div
style={style}
>
{renderButtons()}
</div>
</div>
</div>
);
}

View File

@ -57,7 +57,6 @@ type CardProps = {
planAddonsInfo?: PlanAddonsInfo;
planTrialDisclaimer?: JSX.Element;
isCloud: boolean;
cloudFreeDeprecated: boolean;
}
type StyledProps = {
@ -117,28 +116,20 @@ export function BlankCard() {
function Card(props: CardProps) {
const {formatMessage} = useIntl();
const bottomClassName = classNames('bottom', {
bottom__round: props.cloudFreeDeprecated && props.isCloud,
bottom__round: props.isCloud,
});
const contactSalesCTAClassName = classNames('contact_sales_cta', {
contact_sales_cta__reduced: props.cloudFreeDeprecated && props.isCloud,
contact_sales_cta__reduced: props.isCloud,
});
const planBriefingContentClassName = classNames('plan_briefing_content', {
plan_briefing_content__reduced: props.cloudFreeDeprecated,
});
const planBriefingContentClassName = classNames('plan_briefing_content', 'plan_briefing_content__reduced');
const planPriceRateSectionClassName = classNames('plan_price_rate_section', {
plan_price_rate_section__expanded: props.cloudFreeDeprecated,
});
const planPriceRateSectionClassName = classNames('plan_price_rate_section', 'plan_price_rate_section__expanded');
const planLimitsCtaClassName = classNames('plan_limits_cta', {
plan_limits_cta__expanded: props.cloudFreeDeprecated,
});
const planLimitsCtaClassName = classNames('plan_limits_cta', 'plan_limits_cta__expanded');
const buildingImgClassName = classNames('building_img', {
building_img__expanded: props.cloudFreeDeprecated,
});
const buildingImgClassName = classNames('building_img', 'building_img__expanded');
return (
<div
@ -146,7 +137,7 @@ function Card(props: CardProps) {
className='PlanCard'
>
{props.planLabel}
{(!props.cloudFreeDeprecated || !props.isCloud) && (
{!props.isCloud && (
<StyledDiv
className='top'
bgColor={props.topColor}
@ -159,7 +150,7 @@ function Card(props: CardProps) {
<h3>{props.plan}</h3>
<p>{props.planSummary}</p>
{props.price ? <h1>{props.price}</h1> : <div className={buildingImgClassName}><BuildingSvg/></div>}
{props.cloudFreeDeprecated ? (<span className='plan_rate'>{props.rate}</span>) : (<span>{props.rate}</span>)}
<span className='plan_rate'>{props.rate}</span>
</div>
<div className={planLimitsCtaClassName}>
@ -188,7 +179,6 @@ function Card(props: CardProps) {
</div>
<div className='plan_briefing'>
{!props.cloudFreeDeprecated && <hr/>}
{props.planTrialDisclaimer}
<div className={planBriefingContentClassName}>
<span className='title'>{props.briefing.title}</span>

View File

@ -4,9 +4,7 @@
import React from 'react';
import {Modal} from 'react-bootstrap';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {Feedback} from '@mattermost/types/cloud';
import {useSelector} from 'react-redux';
import {
getCloudSubscription as selectCloudSubscription,
@ -16,34 +14,20 @@ import {
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {subscribeCloudSubscription} from 'actions/cloud';
import {trackEvent} from 'actions/telemetry_actions';
import {closeModal, openModal} from 'actions/views/modals';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import ErrorModal from 'components/cloud_subscribe_result_modal/error';
import SuccessModal from 'components/cloud_subscribe_result_modal/success';
import useGetLimits from 'components/common/hooks/useGetLimits';
import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenDowngradeModal from 'components/common/hooks/useOpenDowngradeModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import PlanLabel from 'components/common/plan_label';
import ExternalLink from 'components/external_link';
import DowngradeFeedbackModal from 'components/feedback_modal/downgrade_feedback';
import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta';
import CheckMarkSvg from 'components/widgets/icons/check_mark_icon';
import {CloudLinks, CloudProducts, LicenseSkus, ModalIdentifiers, MattermostFeatures, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {fallbackStarterLimits, asGBString, hasSomeLimits} from 'utils/limits';
import {CloudProducts, LicenseSkus, MattermostFeatures, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {findOnlyYearlyProducts, findProductBySku} from 'utils/products';
import Card, {BlankCard, ButtonCustomiserClasses} from './card';
import ContactSalesCTA from './contact_sales_cta';
import StartTrialCaution from './start_trial_caution';
import StarterDisclaimerCTA from './starter_disclaimer_cta';
import './content.scss';
@ -57,9 +41,6 @@ type ContentProps = {
function Content(props: ContentProps) {
const {formatMessage, formatNumber} = useIntl();
const dispatch = useDispatch();
const [limits] = useGetLimits();
const openPricingModalBackAction = useOpenPricingModal();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
@ -76,35 +57,16 @@ function Content(props: ContentProps) {
const yearlyProfessionalProduct = findProductBySku(yearlyProducts, CloudProducts.PROFESSIONAL);
const professionalPrice = formatNumber((yearlyProfessionalProduct?.price_per_seat || 0) / 12, {maximumFractionDigits: 2});
const starterProduct = Object.values(products || {}).find(((product) => {
return product.sku === CloudProducts.STARTER;
}));
const isStarter = currentProduct?.sku === CloudProducts.STARTER;
const isProfessional = currentProduct?.sku === CloudProducts.PROFESSIONAL;
const currentSubscriptionIsMonthlyProfessional = currentSubscriptionIsMonthly && isProfessional;
const isProfessionalAnnual = isProfessional && currentProduct?.recurring_interval === RecurringIntervals.YEAR;
const isPreTrial = subscription?.trial_end_at === 0;
let isPostTrial = false;
if ((subscription && subscription.trial_end_at > 0) && !isEnterpriseTrial && (isStarter || isEnterprise)) {
if ((subscription && subscription.trial_end_at > 0) && !isEnterpriseTrial && isEnterprise) {
isPostTrial = true;
}
const [notifyAdminBtnTextProfessional, notifyAdminOnProfessionalFeatures, professionalNotifyRequestStatus] = useNotifyAdmin({
ctaText: formatMessage({id: 'pricing_modal.noitfy_cta.request', defaultMessage: 'Request admin to upgrade'}),
successText: (
<>
<i className='icon icon-check'/>
{formatMessage({id: 'pricing_modal.noitfy_cta.request_success', defaultMessage: 'Request sent'})}
</>),
}, {
required_feature: MattermostFeatures.ALL_PROFESSIONAL_FEATURES,
required_plan: LicenseSkus.Professional,
trial_notification: false,
});
const [notifyAdminBtnTextEnterprise, notifyAdminOnEnterpriseFeatures, enterpriseNotifyRequestStatus] = useNotifyAdmin({
ctaText: formatMessage({id: 'pricing_modal.noitfy_cta.request', defaultMessage: 'Request admin to upgrade'}),
successText: (
@ -123,130 +85,24 @@ function Content(props: ContentProps) {
return formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'});
}
if (cloudFreeDeprecated) {
return formatMessage({id: 'pricing_modal.btn.purchase', defaultMessage: 'Purchase'});
}
return formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'});
return formatMessage({id: 'pricing_modal.btn.purchase', defaultMessage: 'Purchase'});
};
const freeTierText = (!isStarter && !currentSubscriptionIsMonthly) ? formatMessage({id: 'pricing_modal.btn.contactSupport', defaultMessage: 'Contact Support'}) : formatMessage({id: 'pricing_modal.btn.downgrade', defaultMessage: 'Downgrade'});
const adminProfessionalTierText = getAdminProfessionalBtnText();
const [openContactSales] = useOpenSalesLink();
const [openContactSupport] = useOpenCloudZendeskSupportForm('Workspace downgrade', '');
const openCloudPurchaseModal = useOpenCloudPurchaseModal({});
const openCloudDelinquencyModal = useOpenCloudPurchaseModal({
isDelinquencyModal: true,
});
const openDowngradeModal = useOpenDowngradeModal();
const openPurchaseModal = (callerInfo: string) => {
props.onHide();
const telemetryInfo = props.callerCTA + ' > ' + callerInfo;
if (subscription?.delinquent_since) {
openCloudDelinquencyModal({trackingLocation: telemetryInfo});
}
openCloudPurchaseModal({trackingLocation: telemetryInfo});
};
const closePricingModal = () => {
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
};
const handleClickDowngrade = (downgradeFeedback?: Feedback) => {
downgrade('click_pricing_modal_free_card_downgrade_button', downgradeFeedback);
};
const downgrade = async (callerInfo: string, downgradeFeedback?: Feedback) => {
if (!starterProduct) {
return;
}
const telemetryInfo = props.callerCTA + ' > ' + callerInfo;
openDowngradeModal({trackingLocation: telemetryInfo});
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
const result = await dispatch(subscribeCloudSubscription(starterProduct.id, undefined, 0, downgradeFeedback));
if (result.error) {
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
dispatch(closeModal(ModalIdentifiers.CLOUD_DOWNGRADE_CHOOSE_TEAM));
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
dispatch(
openModal({
modalId: ModalIdentifiers.ERROR_MODAL,
dialogType: ErrorModal,
dialogProps: {
backButtonAction: openPricingModalBackAction,
},
}),
);
return;
}
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
dispatch(closeModal(ModalIdentifiers.CLOUD_DOWNGRADE_CHOOSE_TEAM));
dispatch(
openModal({
modalId: ModalIdentifiers.SUCCESS_MODAL,
dialogType: SuccessModal,
}),
);
props.onHide();
};
const hasLimits = hasSomeLimits(limits);
const starterBriefing = [
formatMessage({id: 'pricing_modal.briefing.free.recentMessageBoards', defaultMessage: 'Access to {messages} most recent messages'}, {messages: formatNumber(fallbackStarterLimits.messages.history)}),
formatMessage({id: 'pricing_modal.briefing.storageStarter', defaultMessage: '{storage} file storage limit'}, {storage: asGBString(fallbackStarterLimits.files.totalStorage, formatNumber)}),
formatMessage({id: 'pricing_modal.briefing.free.noLimitBoards', defaultMessage: 'Unlimited board cards'}),
formatMessage({id: 'pricing_modal.briefing.free.oneTeamPerWorkspace', defaultMessage: 'One team per workspace'}),
formatMessage({id: 'pricing_modal.briefing.free.gitLabGitHubGSuite', defaultMessage: 'GitLab, GitHub, and GSuite SSO'}),
formatMessage({id: 'pricing_modal.extra_briefing.cloud.free.calls', defaultMessage: 'Group calls of up to 8 people, 1:1 calls, and screen share'}),
];
const legacyStarterBriefing = [
formatMessage({id: 'admin.billing.subscription.planDetails.features.groupAndOneToOneMessaging', defaultMessage: 'Group and one-to-one messaging, file sharing, and search'}),
formatMessage({id: 'admin.billing.subscription.planDetails.features.incidentCollaboration', defaultMessage: 'Incident collaboration'}),
formatMessage({id: 'admin.billing.subscription.planDetails.features.unlimittedUsersAndMessagingHistory', defaultMessage: 'Unlimited users & message history'}),
formatMessage({id: 'admin.billing.subscription.planDetails.features.mfa', defaultMessage: 'Multi-Factor Authentication (MFA)'}),
];
const professionalBtnDetails = () => {
if (isAdmin) {
return {
action: () => openPurchaseModal('click_pricing_modal_professional_card_upgrade_button'),
text: adminProfessionalTierText,
disabled: isProfessionalAnnual || (isEnterprise && !isEnterpriseTrial),
customClass: (cloudFreeDeprecated || isPostTrial) ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.active,
};
}
let trialBtnClass = ButtonCustomiserClasses.special;
if (isPostTrial) {
trialBtnClass = ButtonCustomiserClasses.special;
} else {
trialBtnClass = ButtonCustomiserClasses.active;
}
if (professionalNotifyRequestStatus === NotifyStatus.Success) {
trialBtnClass = ButtonCustomiserClasses.green;
}
return {
action: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
notifyAdminOnProfessionalFeatures(e, 'professional_plan_pricing_modal_card');
},
text: notifyAdminBtnTextProfessional,
disabled: isProfessional || (isEnterprise && !isEnterpriseTrial),
customClass: trialBtnClass,
action: () => { },
text: adminProfessionalTierText,
disabled: true,
customClass: (isPostTrial) ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.active,
};
};
const enterpriseBtnDetails = () => {
if (cloudFreeDeprecated || (isPostTrial && isAdmin)) {
if (isAdmin) {
return {
action: () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales');
@ -257,48 +113,28 @@ function Content(props: ContentProps) {
};
}
if (!isAdmin) {
let trialBtnClass = ButtonCustomiserClasses.special;
if (isPostTrial) {
trialBtnClass = ButtonCustomiserClasses.special;
} else {
trialBtnClass = ButtonCustomiserClasses.active;
}
if (enterpriseNotifyRequestStatus === NotifyStatus.Success) {
trialBtnClass = ButtonCustomiserClasses.green;
}
return {
action: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
notifyAdminOnEnterpriseFeatures(e, 'enterprise_plan_pricing_modal_card');
},
text: notifyAdminBtnTextEnterprise,
disabled: isEnterprise,
customClass: trialBtnClass,
};
let trialBtnClass = ButtonCustomiserClasses.special;
if (isPostTrial) {
trialBtnClass = ButtonCustomiserClasses.special;
} else {
trialBtnClass = ButtonCustomiserClasses.active;
}
return undefined;
};
const enterpriseCustomBtnDetails = () => {
if (!isPostTrial && isAdmin && !cloudFreeDeprecated) {
return (
<CloudStartTrialButton
message={formatMessage({id: 'pricing_modal.btn.tryDays', defaultMessage: 'Try free for {days} days'}, {days: '30'})}
telemetryId='start_cloud_trial_from_pricing_modal'
disabled={isEnterprise || isEnterpriseTrial || isProfessional}
extraClass={`plan_action_btn ${(isEnterprise || isEnterpriseTrial || isProfessional) ? ButtonCustomiserClasses.grayed : ButtonCustomiserClasses.special}`}
afterTrialRequest={closePricingModal}
/>
);
if (enterpriseNotifyRequestStatus === NotifyStatus.Success) {
trialBtnClass = ButtonCustomiserClasses.green;
}
return undefined;
return {
action: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
notifyAdminOnEnterpriseFeatures(e, 'enterprise_plan_pricing_modal_card');
},
text: notifyAdminBtnTextEnterprise,
disabled: isEnterprise,
customClass: trialBtnClass,
};
};
const professionalPlanLabelText = () => {
if (isProfessionalAnnual || !isAdmin) {
if (isProfessional || !isAdmin) {
return formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'});
}
@ -325,81 +161,11 @@ function Content(props: ContentProps) {
</button>
</Modal.Header>
<Modal.Body>
{!cloudFreeDeprecated && (
<div className='pricing-options-container'>
<div className='alert-option-container'>
<div className='alert-option'>
<span>{formatMessage({id: 'pricing_modal.lookingToSelfHost', defaultMessage: 'Looking to self-host?'})}</span>
<ExternalLink
onClick={() =>
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'click_looking_to_self_host',
)
}
href={CloudLinks.DEPLOYMENT_OPTIONS}
location='pricing_modal_content'
>{formatMessage({id: 'pricing_modal.reviewDeploymentOptions', defaultMessage: 'Review deployment options'})}</ExternalLink>
</div>
</div>
</div>
)}
<div
className='PricingModal__body'
style={{marginTop: cloudFreeDeprecated ? '74px' : ''}}
style={{marginTop: '74px'}}
>
{!cloudFreeDeprecated && (
<Card
id='free'
topColor='#339970'
plan='Free'
planSummary={formatMessage({id: 'pricing_modal.planSummary.free', defaultMessage: 'Increased productivity for small teams'})}
price='$0'
isCloud={true}
cloudFreeDeprecated={cloudFreeDeprecated}
planLabel={
isStarter ? (
<PlanLabel
text={formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'})}
color='var(--denim-status-online)'
bgColor='var(--center-channel-bg)'
firstSvg={<CheckMarkSvg/>}
/>) : undefined}
planExtraInformation={<StarterDisclaimerCTA/>}
buttonDetails={{
action: () => {
if (!isStarter && !currentSubscriptionIsMonthly) {
openContactSupport();
return;
}
if (!starterProduct) {
return;
}
dispatch(
openModal({
modalId: ModalIdentifiers.FEEDBACK,
dialogType: DowngradeFeedbackModal,
dialogProps: {
onSubmit: handleClickDowngrade,
},
}),
);
},
text: freeTierText,
disabled: isStarter || isEnterprise || !isAdmin,
customClass: (isStarter || isEnterprise || !isAdmin) ? ButtonCustomiserClasses.grayed : ButtonCustomiserClasses.secondary,
}}
briefing={{
title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}),
items: hasLimits ? starterBriefing : legacyStarterBriefing,
}}
/>
)
}
{isProfessional &&
<Card
id='professional'
topColor='var(--denim-button-bg)'
@ -412,14 +178,11 @@ function Content(props: ContentProps) {
br: <br/>,
b: (chunks: React.ReactNode | React.ReactNodeArray) => (
<span className='billed_annually'>
{
cloudFreeDeprecated ? chunks : (<b>{chunks}</b>)
}
{chunks}
</span>
),
})}
isCloud={true}
cloudFreeDeprecated={cloudFreeDeprecated}
planLabel={isProfessional ? (
<PlanLabel
text={professionalPlanLabelText()}
@ -429,7 +192,7 @@ function Content(props: ContentProps) {
/>) : undefined}
buttonDetails={professionalBtnDetails()}
briefing={{
title: cloudFreeDeprecated ? formatMessage({id: 'pricing_modal.briefing.title_no_limit', defaultMessage: 'No limits on your teams usage'}) : formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}),
title: formatMessage({id: 'pricing_modal.briefing.title_no_limit', defaultMessage: 'No limits on your teams usage'}),
items: [
formatMessage({id: 'pricing_modal.briefing.professional.messageBoardsIntegrationsCalls', defaultMessage: 'Unlimited access to messages and files'}),
formatMessage({id: 'pricing_modal.briefing.professional.unLimitedTeams', defaultMessage: 'Unlimited teams'}),
@ -439,7 +202,7 @@ function Content(props: ContentProps) {
formatMessage({id: 'pricing_modal.extra_briefing.professional.guestAccess', defaultMessage: 'Guest access with MFA enforcement'}),
],
}}
/>
/>}
<Card
id='enterprise'
@ -447,7 +210,6 @@ function Content(props: ContentProps) {
plan='Enterprise'
planSummary={formatMessage({id: 'pricing_modal.planSummary.enterprise', defaultMessage: 'Administration, security, and compliance for large teams'})}
isCloud={true}
cloudFreeDeprecated={cloudFreeDeprecated}
planLabel={
isEnterprise ? (
<PlanLabel
@ -458,7 +220,6 @@ function Content(props: ContentProps) {
renderLastDaysOnTrial={true}
/>) : undefined}
buttonDetails={enterpriseBtnDetails()}
customButtonDetails={enterpriseCustomBtnDetails()}
planTrialDisclaimer={(!isPostTrial && isAdmin && !cloudFreeDeprecated) ? <StartTrialCaution/> : undefined}
contactSalesCTA={(isPostTrial || !isAdmin || cloudFreeDeprecated) ? undefined : <ContactSalesCTA/>}
briefing={{
@ -486,7 +247,7 @@ function Content(props: ContentProps) {
],
}}
/>
{cloudFreeDeprecated && <BlankCard/>}
<BlankCard/>
</div>
</Modal.Body>
</div>

View File

@ -10,29 +10,20 @@ import type {GlobalState} from '@mattermost/types/store';
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
import {Client4} from 'mattermost-redux/client';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {trackEvent} from 'actions/telemetry_actions';
import {closeModal} from 'actions/views/modals';
import useCanSelfHostedSignup from 'components/common/hooks/useCanSelfHostedSignup';
import {
useControlAirGappedSelfHostedPurchaseModal,
useControlScreeningInProgressModal,
} from 'components/common/hooks/useControlModal';
import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal';
import useFetchAdminConfig from 'components/common/hooks/useFetchAdminConfig';
import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import PlanLabel from 'components/common/plan_label';
import ExternalLink from 'components/external_link';
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
import CheckMarkSvg from 'components/widgets/icons/check_mark_icon';
import {CloudLinks, ModalIdentifiers, SelfHostedProducts, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import {CloudLinks, ModalIdentifiers, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import Card, {ButtonCustomiserClasses} from './card';
import ContactSalesCTA from './contact_sales_cta';
@ -51,13 +42,7 @@ function SelfHostedContent(props: ContentProps) {
useFetchAdminConfig();
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const signupAvailable = useCanSelfHostedSignup();
const [products, productsLoaded] = useGetSelfHostedProducts();
const professionalProductId = findSelfHostedProductBySku(products, SelfHostedProducts.PROFESSIONAL)?.id || '';
const controlSelfHostedPurchaseModal = useControlSelfHostedPurchaseModal({productId: professionalProductId});
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const [openSalesLink] = useOpenSalesLink();
useEffect(() => {
dispatch(getPrevTrialLicense());
@ -91,8 +76,6 @@ function SelfHostedContent(props: ContentProps) {
const isPostSelfHostedEnterpriseTrial = prevSelfHostedTrialLicense.IsLicensed === 'true';
const [openContactSales] = useOpenSalesLink();
const controlScreeningInProgressModal = useControlScreeningInProgressModal();
const controlAirgappedModal = useControlAirGappedSelfHostedPurchaseModal();
const closePricingModal = () => {
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
@ -188,7 +171,6 @@ function SelfHostedContent(props: ContentProps) {
planSummary={formatMessage({id: 'pricing_modal.planSummary.free', defaultMessage: 'Increased productivity for small teams'})}
price='$0'
isCloud={false}
cloudFreeDeprecated={false}
planLabel={
isStarter ? (
<PlanLabel
@ -225,7 +207,6 @@ function SelfHostedContent(props: ContentProps) {
),
})}
isCloud={false}
cloudFreeDeprecated={false}
planLabel={
isProfessional ? (
<PlanLabel
@ -237,37 +218,7 @@ function SelfHostedContent(props: ContentProps) {
buttonDetails={{
action: () => {
trackEvent('self_hosted_pricing', 'click_upgrade_button');
if (!isSelfHostedPurchaseEnabled) {
window.open(CloudLinks.SELF_HOSTED_SIGNUP, '_blank');
return;
}
if (!signupAvailable.ok) {
if (signupAvailable.cwsContacted && !signupAvailable.cwsServiceOn) {
window.open(CloudLinks.SELF_HOSTED_SIGNUP, '_blank');
return;
}
if (signupAvailable.screeningInProgress) {
controlScreeningInProgressModal.open();
} else {
controlAirgappedModal.open();
}
closePricingModal();
return;
}
const professionalProduct = findSelfHostedProductBySku(products, SelfHostedProducts.PROFESSIONAL);
if (productsLoaded && professionalProduct) {
// let the control modal close this modal
// we need to wait for its timing,
// it doesn't return a signal,
// and we can not do this in a useEffect hook
// at the top of this file easily because
// sometimes we want both modals to be open if user is in purchase
// modal and wants to compare plans
controlSelfHostedPurchaseModal.open();
}
openSalesLink();
},
text: formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}),
disabled: !isAdmin || isProfessional,
@ -285,7 +236,6 @@ function SelfHostedContent(props: ContentProps) {
plan='Enterprise'
planSummary={formatMessage({id: 'pricing_modal.planSummary.enterprise', defaultMessage: 'Administration, security, and compliance for large teams'})}
isCloud={false}
cloudFreeDeprecated={false}
planLabel={
isEnterprise ? (
<PlanLabel

View File

@ -1,14 +0,0 @@
.PurchaseInProgressModal {
&__body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
&__progress-description {
padding-top: 20px;
}
}

View File

@ -1,88 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchases/constants';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper as TH} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import PurchaseInProgressModal from './';
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
return {
__esModule: true,
...original,
Client4: {
...original,
bootstrapSelfHostedSignup: jest.fn(),
},
};
});
const initialState: DeepPartial<GlobalState> = {
entities: {
preferences: {
myPreferences: {
theme: {},
},
},
users: {
currentUserId: 'adminUserId',
profiles: {
adminUserId: TH.getUserMock({
id: 'adminUserId',
username: 'UserAdmin',
roles: 'admin',
email: 'admin@example.com',
}),
otherUserId: TH.getUserMock({
id: 'otherUserId',
username: 'UserOther',
roles: '',
email: 'other-user@example.com',
}),
},
},
},
};
describe('PurchaseInProgressModal', () => {
it('when purchaser and user emails are different, user is instructed to wait', () => {
const stateOverride: DeepPartial<GlobalState> = JSON.parse(JSON.stringify(initialState));
stateOverride.entities!.users!.currentUserId = 'otherUserId';
renderWithContext(
<div id='root-portal'>
<PurchaseInProgressModal
purchaserEmail={'admin@example.com'}
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
/>
</div>, stateOverride,
);
screen.getByText('@UserAdmin is currently attempting to purchase a paid license.');
});
it('when purchaser and user emails are same, allows user to reset purchase flow', () => {
renderWithContext(
<div id='root-portal'>
<PurchaseInProgressModal
purchaserEmail={'admin@example.com'}
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
/>
</div>, initialState,
);
expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled();
screen.getByText('Reset purchase flow').click();
expect(Client4.bootstrapSelfHostedSignup).toHaveBeenCalled();
});
});

View File

@ -1,104 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import type {GenericModalProps} from '@mattermost/components';
import {GenericModal} from '@mattermost/components';
import type {GlobalState} from '@mattermost/types/store';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getUserByEmail} from 'mattermost-redux/selectors/entities/users';
import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import './index.scss';
interface Props {
purchaserEmail: string;
storageKey: string;
}
export default function PurchaseInProgressModal(props: Props) {
const {close} = useControlPurchaseInProgressModal();
const currentUser = useSelector(getCurrentUser);
const purchaserUser = useSelector((state: GlobalState) => getUserByEmail(state, props.purchaserEmail));
const header = (
<FormattedMessage
id='self_hosted_signup.purchase_in_progress.title'
defaultMessage='Purchase in progress'
/>
);
const sameUserAlreadyPurchasing = props.purchaserEmail === currentUser.email;
let username = '@' + purchaserUser.username;
if (purchaserUser.first_name && purchaserUser.last_name) {
username = purchaserUser.first_name + ' ' + purchaserUser.last_name;
}
let description = (
<FormattedMessage
id='self_hosted_signup.purchase_in_progress.by_other'
defaultMessage='{username} is currently attempting to purchase a paid license.'
values={{
username,
}}
/>
);
let actionToTake;
const genericModalProps: Partial<GenericModalProps> = {};
if (sameUserAlreadyPurchasing) {
description = (
<FormattedMessage
id='self_hosted_signup.purchase_in_progress.by_self'
defaultMessage='You are currently attempting to purchase in another browser window. Complete your purchase or close the other window(s).'
/>
);
actionToTake = (
<FormattedMessage
id='self_hosted_signup.purchase_in_progress.by_self_restart'
defaultMessage='If you believe this to be a mistake, restart your purchase.'
/>
);
genericModalProps.handleConfirm = () => {
localStorage.removeItem(props.storageKey);
Client4.bootstrapSelfHostedSignup(true);
close();
};
genericModalProps.confirmButtonText = (
<FormattedMessage
id='self_hosted_signup.purchase_in_progress.reset'
defaultMessage='Reset purchase flow'
/>
);
}
return (
<GenericModal
onExited={close}
show={true}
modalHeaderText={header}
compassDesign={true}
className='PurchaseInProgressModal'
{...genericModalProps}
>
<div className='PurchaseInProgressModal__body'>
<CreditCardSvg
height={350}
width={350}
/>
<div className='PurchaseInProgressModal__progress-description'>
{description}
</div>
{actionToTake &&
<div className='PurchaseInProgressModal__progress-description'>
{actionToTake}
</div>
}
</div>
</GenericModal>
);
}

View File

@ -1,119 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import {TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants';
import type {ButtonDetails} from './purchase_modal';
type DelinquencyCardProps = {
topColor: string;
price: string;
buttonDetails: ButtonDetails;
onViewBreakdownClick: () => void;
isCloudDelinquencyGreaterThan90Days: boolean;
users: number;
cost: number;
};
export default function DelinquencyCard(props: DelinquencyCardProps) {
const handleSeeHowBillingWorksClick = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_see_how_billing_works',
);
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
};
const seeHowBillingWorks = (
<a onClick={handleSeeHowBillingWorksClick}>
<FormattedMessage
defaultMessage={'See how billing works.'}
id={
'admin.billing.subscription.howItWorks'
}
/>
</a>
);
return (
<div className='PlanCard'>
<div
className='top'
style={{backgroundColor: props.topColor}}
/>
<div className='bottom delinquency'>
<div className='delinquency_summary_section'>
<div className={'summary-section'}>
<div className='summary-title'>
<FormattedMessage
id={'cloud_delinquency.cc_modal.card.totalOwed'}
defaultMessage={'Total Owed'}
/>
{':'}
</div>
<div className='summary-total'>{props.price}</div>
<div
onClick={props.onViewBreakdownClick}
className='view-breakdown'
>
<FormattedMessage
defaultMessage={'View Breakdown'}
id={
'cloud_delinquency.cc_modal.card.viewBreakdown'
}
/>
</div>
</div>
</div>
<div>
<button
className={
'plan_action_btn ' + props.buttonDetails.customClass
}
disabled={props.buttonDetails.disabled}
onClick={props.buttonDetails.action}
>
{props.buttonDetails.text}
</button>
</div>
<div className='plan_billing_cycle delinquency'>
{Boolean(!props.isCloudDelinquencyGreaterThan90Days) && (
<FormattedMessage
defaultMessage={
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}'
}
id={'cloud_delinquency.cc_modal.disclaimer'}
values={{
seeHowBillingWorks,
}}
/>
)}
{Boolean(props.isCloudDelinquencyGreaterThan90Days) && (
<FormattedMessage
defaultMessage={
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. You\'ll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}'
}
id={
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
}
values={{
cost: `$${props.cost}`,
users: props.users,
seeHowBillingWorks,
}}
/>
)}
</div>
</div>
</div>
);
}

View File

@ -107,4 +107,4 @@
}
}
}
}
}

View File

@ -1,91 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getCloudProducts, getCloudSubscription, getInvoices} from 'mattermost-redux/actions/cloud';
import {getClientConfig} from 'mattermost-redux/actions/general';
import {getAdminAnalytics, getConfig} from 'mattermost-redux/selectors/entities/admin';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {completeStripeAddPaymentMethod, subscribeCloudSubscription} from 'actions/cloud';
import {closeModal, openModal} from 'actions/views/modals';
import {getCloudDelinquentInvoices, isCloudDelinquencyGreaterThan90Days, isCwsMockMode} from 'selectors/cloud';
import {isModalOpen} from 'selectors/views/modals';
import {makeAsyncComponent} from 'components/async_load';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import {getStripePublicKey} from 'components/payment_form/stripe';
import {daysToExpiration} from 'utils/cloud_utils';
import {ModalIdentifiers} from 'utils/constants';
import {getCloudContactSalesLink, getCloudSupportLink} from 'utils/contact_support_sales';
import {findOnlyYearlyProducts} from 'utils/products';
import type {GlobalState} from 'types/store';
const PurchaseModal = makeAsyncComponent('PurchaseModal', React.lazy(() => import('./purchase_modal')));
function mapStateToProps(state: GlobalState) {
const subscription = state.entities.cloud.subscription;
const isDelinquencyModal = Boolean(state.entities.cloud.subscription?.delinquent_since);
const isRenewalModal = daysToExpiration(state.entities.cloud.subscription) <= 60 && state.entities.cloud.subscription?.is_free_trial === 'false' && !isDelinquencyModal && getConfig(state).FeatureFlags?.CloudAnnualRenewals;
const products = state.entities.cloud!.products;
const yearlyProducts = findOnlyYearlyProducts(products || {});
const customer = state.entities.cloud.customer;
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
const contactSalesLink = getCloudContactSalesLink(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud');
const contactSupportLink = getCloudSupportLink(customerEmail, 'Cloud purchase', '', window.location.host);
const stripePublicKey = getStripePublicKey(state);
return {
show: isModalOpen(state, ModalIdentifiers.CLOUD_PURCHASE),
products,
yearlyProducts,
cwsMockMode: isCwsMockMode(state),
contactSupportLink,
invoices: getCloudDelinquentInvoices(state),
isCloudDelinquencyGreaterThan90Days: isCloudDelinquencyGreaterThan90Days(state),
isFreeTrial: subscription?.is_free_trial === 'true',
isComplianceBlocked: subscription?.compliance_blocked === 'true',
contactSalesLink,
productId: subscription?.product_id,
customer,
currentTeam: getCurrentTeam(state),
theme: getTheme(state),
isDelinquencyModal,
isRenewalModal,
usersCount: Number(getAdminAnalytics(state)!.TOTAL_USERS) || 1,
stripePublicKey,
subscription,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators(
{
closeModal: () => closeModal(ModalIdentifiers.CLOUD_PURCHASE),
openModal,
getCloudProducts,
completeStripeAddPaymentMethod,
subscribeCloudSubscription,
getClientConfig,
getInvoices,
getCloudSubscription,
},
dispatch,
),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(withGetCloudSubscription(PurchaseModal));

View File

@ -1,19 +0,0 @@
.ProcessPayment-progress {
width: 387px;
height: 4px;
border-radius: 4px;
margin: 0 auto;
margin-top: 60px;
background: rgb(34, 64, 109, 0.12);
}
.ProcessPayment-progress-fill {
height: 100%;
border-radius: 4px;
background: #0058cc;
}
.ProcessPayment-body {
overflow-x: hidden;
overflow-y: hidden;
}

View File

@ -1,420 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Stripe} from '@stripe/stripe-js';
import React from 'react';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import {withRouter} from 'react-router-dom';
import type {RouteComponentProps} from 'react-router-dom';
import type {Address, CloudCustomerPatch, Feedback, Product} from '@mattermost/types/cloud';
import type {Team} from '@mattermost/types/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {pageVisited, trackEvent} from 'actions/telemetry_actions';
import ComplianceScreenFailedSvg from 'components/common/svg_images_components/access_denied_happy_svg';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg';
import {RecurringIntervals, TELEMETRY_CATEGORIES} from 'utils/constants';
import {t} from 'utils/i18n';
import {getNextBillingDate} from 'utils/utils';
import type {BillingDetails} from 'types/cloud/sku';
import IconMessage from './icon_message';
import './process_payment.css';
type ComplianceError = {
error: string;
status: number;
}
type Props = RouteComponentProps & {
billingDetails: BillingDetails | null;
shippingAddress: Address | null;
stripe: Promise<Stripe | null>;
cwsMockMode: boolean;
contactSupportLink: string;
currentTeam: Team;
addPaymentMethod: (
stripe: Stripe,
billingDetails: BillingDetails,
cwsMockMode: boolean
) => Promise<boolean | null>;
subscribeCloudSubscription:
| ((productId: string, shippingAddress: Address, seats?: number, downgradeFeedback?: Feedback, customerPatch?: CloudCustomerPatch) => Promise<ActionResult<Subscription, ComplianceError>>)
| null;
onBack: () => void;
onClose: () => void;
selectedProduct?: Product | null | undefined;
currentProduct?: Product | null | undefined;
isProratedPayment?: boolean;
isUpgradeFromTrial: boolean;
setIsUpgradeFromTrialToFalse: () => void;
telemetryProps?: { callerInfo: string };
onSuccess?: () => void;
intl: IntlShape;
usersCount: number;
isSwitchingToAnnual: boolean;
};
type State = {
progress: number;
error: boolean;
state: ProcessState;
}
enum ProcessState {
PROCESSING = 0,
SUCCESS,
FAILED,
FAILED_COMPLIANCE_SCREEN,
}
const MIN_PROCESSING_MILLISECONDS = 5000;
const MAX_FAKE_PROGRESS = 95;
class ProcessPaymentSetup extends React.PureComponent<Props, State> {
intervalId: NodeJS.Timeout;
public constructor(props: Props) {
super(props);
this.intervalId = {} as NodeJS.Timeout;
this.state = {
progress: 0,
error: false,
state: ProcessState.PROCESSING,
};
}
public componentDidMount() {
this.savePaymentMethod();
this.intervalId = setInterval(this.updateProgress, MIN_PROCESSING_MILLISECONDS / MAX_FAKE_PROGRESS);
}
public componentWillUnmount() {
clearInterval(this.intervalId);
}
private updateProgress = () => {
let {progress} = this.state;
if (progress >= MAX_FAKE_PROGRESS) {
clearInterval(this.intervalId);
return;
}
progress += 1;
this.setState({progress: progress > MAX_FAKE_PROGRESS ? MAX_FAKE_PROGRESS : progress});
};
private savePaymentMethod = async () => {
const start = new Date();
const {
stripe,
addPaymentMethod,
billingDetails,
cwsMockMode,
subscribeCloudSubscription,
} = this.props;
const success = await addPaymentMethod((await stripe)!, billingDetails!, cwsMockMode);
if (typeof success !== 'boolean' || !success) {
trackEvent('cloud_admin', 'complete_payment_failed', {
callerInfo: this.props.telemetryProps?.callerInfo,
});
this.setState({
error: true,
state: ProcessState.FAILED});
return;
}
if (subscribeCloudSubscription) {
const customerPatch = {
name: billingDetails?.company_name,
} as CloudCustomerPatch;
const result = await subscribeCloudSubscription(this.props.selectedProduct?.id as string, this.props.shippingAddress as Address, this.props.usersCount, undefined, customerPatch);
// the action subscribeCloudSubscription returns a true boolean when successful and an error when it fails
if (result.error) {
trackEvent('cloud_admin', 'complete_payment_failed_compliance_screen', {
callerInfo: this.props.telemetryProps?.callerInfo,
});
if (result.error.status === 422) {
this.setState({
error: true,
state: ProcessState.FAILED_COMPLIANCE_SCREEN,
});
return;
}
trackEvent('cloud_admin', 'complete_payment_failed', {
callerInfo: this.props.telemetryProps?.callerInfo,
});
this.setState({
error: true,
state: ProcessState.FAILED});
return;
}
}
const end = new Date();
const millisecondsElapsed = end.valueOf() - start.valueOf();
if (millisecondsElapsed < MIN_PROCESSING_MILLISECONDS) {
setTimeout(this.completePayment, MIN_PROCESSING_MILLISECONDS - millisecondsElapsed);
return;
}
this.completePayment();
};
private completePayment = () => {
clearInterval(this.intervalId);
trackEvent('cloud_admin', 'complete_payment_success', {
callerInfo: this.props.telemetryProps?.callerInfo,
});
pageVisited(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'pageview_payment_success',
);
this.setState({state: ProcessState.SUCCESS, progress: 100});
};
private handleGoBack = () => {
clearInterval(this.intervalId);
this.setState({
progress: 0,
error: false,
state: ProcessState.PROCESSING,
});
this.props.onBack();
};
private successPage = () => {
const {error} = this.state;
const formattedBtnText = (
<FormattedMessage
defaultMessage='Return to {team}'
id='admin.billing.subscription.returnToTeam'
values={{
team: this.props.currentTeam?.display_name || this.props.intl.formatMessage({
id: 'admin.sidebarHeader.systemConsole',
defaultMessage: 'System Console',
}),
}}
/>
);
if (this.props.isProratedPayment) {
const formattedTitle = (
<FormattedMessage
defaultMessage={'You are now subscribed to {selectedProductName}'}
id={'admin.billing.subscription.proratedPayment.title'}
values={{selectedProductName: this.props.selectedProduct?.name}}
/>
);
const formattedSubtitle = (
<FormattedMessage
defaultMessage={"Thank you for upgrading to {selectedProductName}. Check your workspace in a few minutes to access all the plan's features. You'll be charged a prorated amount for your {currentProductName} plan and {selectedProductName} plan based on the number of days left in the billing cycle and number of users you have."}
id={'admin.billing.subscription.proratedPayment.substitle'}
values={{selectedProductName: this.props.selectedProduct?.name, currentProductName: this.props.currentProduct?.name}}
/>
);
return (
<>
<IconMessage
formattedTitle={formattedTitle}
formattedSubtitle={formattedSubtitle}
date={getNextBillingDate()}
error={error}
icon={
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
}
formattedButtonText={formattedBtnText}
buttonHandler={this.props.onClose}
className={'success'}
/>
</>
);
} else if (this.props.isSwitchingToAnnual) {
const formattedTitle = (
<FormattedMessage
defaultMessage={"You're now switched to {selectedProductName} annual"}
id={'admin.billing.subscription.switchedToAnnual.title'}
values={{selectedProductName: this.props.selectedProduct?.name}}
/>
);
return (
<>
<IconMessage
formattedTitle={formattedTitle}
icon={
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
}
formattedButtonText={formattedBtnText}
buttonHandler={this.props.onClose}
tertiaryBtnText={t('admin.billing.subscription.viewBilling')}
tertiaryButtonHandler={() => {
this.props.onClose();
this.props.history.push('/admin_console/billing/subscription');
}}
className={'success'}
/>
</>
);
}
const productName = this.props.selectedProduct?.name;
const title = (
<FormattedMessage
id={'admin.billing.subscription.upgradedSuccess'}
defaultMessage={'You\'re now upgraded to {productName}'}
values={{productName}}
/>
);
let handleClose = () => {
this.props.onClose();
};
if (typeof this.props.onSuccess === 'function') {
this.props.onSuccess();
}
// if is the first purchase, show a different success purchasing title
if (this.props.isUpgradeFromTrial) {
handleClose = () => {
// set the property isUpgrading to false onClose since we can not use directly isFreeTrial because of component rerendering
this.props.setIsUpgradeFromTrialToFalse();
this.props.onClose();
};
}
const formattedSubtitle = this.props.selectedProduct?.recurring_interval === RecurringIntervals.YEAR ? (
<FormattedMessage
defaultMessage={'{productName} features are now available and ready to use.'}
id={'admin.billing.subscription.featuresAvailable'}
values={{productName}}
/>
) : (
<FormattedMessage
id='admin.billing.subscription.nextBillingDate'
defaultMessage='Starting from {date}, you will be billed for the {productName} plan. You can change your plan whenever you like and we will pro-rate the charges.'
values={{date: getNextBillingDate(), productName}}
/>
);
return (
<IconMessage
formattedTitle={title}
formattedSubtitle={formattedSubtitle}
error={error}
icon={
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
}
formattedButtonText={formattedBtnText}
buttonHandler={handleClose}
className={'success'}
tertiaryBtnText={t('admin.billing.subscription.viewBilling')}
tertiaryButtonHandler={() => {
this.props.onClose();
this.props.history.push('/admin_console/billing/subscription');
}}
/>
);
};
public render() {
const {state, progress, error} = this.state;
const progressBar: JSX.Element | null = (
<div className='ProcessPayment-progress'>
<div
className='ProcessPayment-progress-fill'
style={{width: `${progress}%`}}
/>
</div>
);
switch (state) {
case ProcessState.PROCESSING:
return (
<IconMessage
title={t('admin.billing.subscription.verifyPaymentInformation')}
subtitle={''}
icon={
<CreditCardSvg
width={444}
height={313}
/>
}
footer={progressBar}
className={'processing'}
/>
);
case ProcessState.SUCCESS:
return this.successPage();
case ProcessState.FAILED_COMPLIANCE_SCREEN:
return (
<IconMessage
title={t(
'admin.billing.subscription.complianceScreenFailed.title',
)}
icon={
<ComplianceScreenFailedSvg
width={444}
height={313}
/>
}
error={error}
buttonText={t(
'admin.billing.subscription.complianceScreenFailed.button',
)}
buttonHandler={() => this.props.onClose()}
linkText={t(
'admin.billing.subscription.privateCloudCard.contactSupport',
)}
linkURL={this.props.contactSupportLink}
className={'failed'}
/>
);
case ProcessState.FAILED:
return (
<IconMessage
title={t('admin.billing.subscription.paymentVerificationFailed')}
subtitle={t('admin.billing.subscription.paymentFailed')}
icon={
<PaymentFailedSvg
width={444}
height={313}
/>
}
error={error}
buttonText={t('admin.billing.subscription.goBackTryAgain')}
buttonHandler={this.handleGoBack}
linkText={t('admin.billing.subscription.privateCloudCard.contactSupport')}
linkURL={this.props.contactSupportLink}
className={'failed'}
/>
);
default:
return null;
}
}
}
export default injectIntl(withRouter(ProcessPaymentSetup));

View File

@ -1,547 +0,0 @@
.PurchaseModal {
overflow: hidden;
height: 100%;
& &__purchase-body {
overflow-y: auto;
}
>div {
&.processing {
display: none;
}
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: flex-start;
justify-content: center;
padding: 40px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
.footer-text {
.normal-payment-text {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 14px;
}
.prorrated-payment-text {
div {
display: inline-block;
margin-right: 4px;
font-family: "compass-icons";
font-size: 16px;
}
}
a {
color: var(--button-bg) !important;
}
}
.fineprint-text {
margin-top: 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
line-height: 16px;
}
.bold-text {
font-weight: 600;
}
.normal-text {
font-weight: normal;
}
.LHS {
width: 25%;
.title {
font-weight: 600;
}
.image {
padding: 32px 0;
}
}
.central-panel {
width: 50%;
padding-top: 80px;
.PaymentDetails {
padding: 10px 0 0 10%;
font-size: 14px;
font-style: normal;
font-weight: normal;
line-height: 20px;
.PaymentInfoDisplay__paymentInfo-text {
width: 416px;
height: 318px;
box-sizing: border-box;
padding: 32px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 4px;
margin: 24px 0;
background-color: #fff;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
.CardImage {
max-width: 55px !important;
max-height: 37px !important;
}
.PaymentInfoDisplay__paymentInfo-cardInfo {
margin: 10px 0;
}
}
.title {
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
}
.editPaymentButton {
padding-left: 20px !important;
border: none;
background: none;
color: var(--button-bg);
font-size: 14px;
font-weight: 600;
line-height: 24px;
}
}
}
.full-width {
border-color: rgba(var(--center-channel-color-rgb), 0.16);
}
.RHS {
position: sticky;
display: flex;
width: 25%;
flex-direction: column;
align-items: center;
.price-text {
display: flex;
align-items: baseline;
padding: 8px 0;
font-size: 32px;
font-weight: 600;
line-height: 1;
.price-decimals {
align-self: end;
padding-bottom: 2px;
font-size: 16px;
&::before {
content: '.';
}
}
}
.monthly-text {
align-self: center;
margin-left: 5px;
font-size: 14px;
font-weight: normal;
}
.plan_comparison {
&.show_label {
margin-bottom: 51px;
}
text-align: center;
button,
a {
width: 100%;
height: 40px;
padding: 10px 24px;
border: none;
background: none;
color: var(--denim-button-bg);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
}
.PlanCard {
position: relative;
width: 280px;
height: auto;
.planLabel {
position: absolute;
top: -44px;
left: 52px;
display: flex;
width: 184px;
align-items: center;
justify-content: center;
padding: 14px;
border-radius: 13px 13px 0 0;
font-family: 'Open Sans';
font-size: 12px;
font-style: normal;
font-weight: 600;
gap: 5px;
line-height: 16px;
}
.top {
height: 18px;
border-radius: 4px 4px 0 0;
}
.bottom {
&:not(.delinquency, .bottom-monthly-yearly) {
height: auto;
}
padding-right: 24px;
padding-bottom: 24px;
padding-left: 24px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 0 0 4px 4px;
background-color: var(--center-channel-bg);
.enable_annual_sub {
margin-top: 12px;
}
.plan_action_btn {
width: 100%;
height: 40px;
padding: 10px 24px;
border: none;
border-radius: 4px;
background: none;
color: var(--denim-button-bg);
font-size: 14px;
font-weight: 700;
line-height: 14px;
&.grayed {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.32);
}
&.active {
border: 1px solid var(--denim-button-bg);
}
&.special {
background: var(--denim-button-bg);
color: var(--button-color);
}
}
.button-description {
margin-top: 8px;
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
text-align: center;
}
.delinquency_summary_section {
width: 100%;
height: 116px;
margin-top: 24px;
margin-bottom: 24px;
font-weight: 400;
letter-spacing: -0.02em;
text-align: center;
.summary-section {
border-radius: 4px;
background-color: rgba(var(--denim-button-bg-rgb), 0.08);
font-family: 'Metropolis';
.summary-title {
padding-top: 12px;
font-size: 14px;
font-weight: 400;
}
.summary-total {
padding-top: 12px;
color: #152234;
font-size: 28px;
font-weight: 700;
line-height: 20px;
}
.view-breakdown {
&:hover {
cursor: pointer;
}
padding-top: 12px;
padding-bottom: 12px;
color: var(--denim-button-bg);
font-family: 'Metropolis';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
}
h1 {
color: var(--center-channel-color);
font-family: 'Metropolis';
font-size: 52px;
font-style: normal;
font-weight: 700;
line-height: 60px;
}
.enterprise_price {
font-size: 32px;
}
p {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: 'Metropolis';
font-size: 18px;
font-style: normal;
line-height: 20px;
}
}
.plan_price_rate_section {
width: 100%;
height: 150px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
font-weight: 400;
letter-spacing: -0.02em;
text-align: center;
h4 {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: 'Metropolis';
font-size: 20px;
font-style: normal;
line-height: 30px;
}
h4.plan_name {
margin-top: 16px;
margin-bottom: 0;
}
h1 {
margin: 0;
color: var(--center-channel-color);
font-family: 'Metropolis';
font-size: 52px;
font-style: normal;
font-weight: 700;
line-height: 60px;
}
.enterprise_price {
font-size: 32px;
}
p {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: 'Metropolis';
font-size: 18px;
font-style: normal;
line-height: 20px;
}
p.plan_text {
margin-bottom: 0;
}
}
.plan_payment_commencement {
margin-top: 16px;
margin-bottom: 24px;
color: var(--center-channel-color-rgb);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.plan_billing_cycle {
&.delinquency {
margin-bottom: 24px;
}
margin-top: 16px;
margin-bottom: 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
table {
width: 100%;
border-collapse: collapse;
.yearly_savings {
padding-top: 12px;
padding-bottom: 12px;
color: var(--denim-status-online);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 20px;
}
.total_price {
color: var(--sys-denim-center-channel-text);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 24px;
}
.monthly_price {
color: var(--sys-denim-center-channel-text);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 20px;
}
}
table tr td:nth-child(2) {
text-align: right;
}
.flex-grid {
display: flex;
margin-top: 20px;
.user_seats_container {
width: 100px;
.user_seats {
height: 24px;
}
.Input_container {
fieldset:not(.Input_fieldset___error) {
margin-bottom: 24px;
}
}
}
.icon {
flex: 1;
align-self: flex-start;
justify-content: left;
.icon-information-outline {
margin: 0;
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
}
}
.Input___customMessage {
width: 250px;
margin-bottom: 4px;
.icon.error {
display: none;
}
}
}
}
.bottom.bottom-monthly-yearly {
display: block;
overflow: auto;
border-radius: 4px;
}
.signup-consequences {
display: flex;
flex-direction: column;
margin-top: 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
span:first-of-type {
margin-bottom: 10px;
}
}
}
}
.logo {
position: absolute;
right: 0;
bottom: 0;
}
button {
width: fit-content;
height: 40px;
border: 0;
border-radius: 4px;
background: var(--button-bg);
color: var(--button-color);
font-size: 14px;
font-weight: 600;
&:disabled {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.32);
}
}
}
}
.background-svg {
position: absolute;
z-index: -1;
top: 0;
>div {
position: absolute;
top: 0;
left: 0;
}
}
.FullScreenModal {
.close-x {
top: 12px;
right: 12px;
}
}

View File

@ -1,871 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {Elements} from '@stripe/react-stripe-js';
import type {Stripe, StripeCardElementChangeEvent} from '@stripe/stripe-js';
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
import classnames from 'classnames';
import isEmpty from 'lodash/isEmpty';
import React from 'react';
import type {ReactNode} from 'react';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import type {Address, CloudCustomer, Product, Invoice, Feedback, Subscription, InvoiceLineItem} from '@mattermost/types/cloud';
import {areShippingDetailsValid} from '@mattermost/types/cloud';
import type {Team} from '@mattermost/types/teams';
import {Client4} from 'mattermost-redux/client';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {trackEvent, pageVisited} from 'actions/telemetry_actions';
import BillingHistoryModal from 'components/admin_console/billing/billing_history_modal';
import PaymentDetails from 'components/admin_console/billing/payment_details';
import CloudInvoicePreview from 'components/cloud_invoice_preview';
import PlanLabel from 'components/common/plan_label';
import ComplianceScreenFailedSvg from 'components/common/svg_images_components/access_denied_happy_svg';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import ExternalLink from 'components/external_link';
import AddressForm from 'components/payment_form/address_form';
import PaymentForm from 'components/payment_form/payment_form';
import {STRIPE_CSS_SRC} from 'components/payment_form/stripe';
import PricingModal from 'components/pricing_modal';
import RootPortal from 'components/root_portal';
import SeatsCalculator, {errorInvalidNumber} from 'components/seats_calculator';
import type {Seats} from 'components/seats_calculator';
import Consequences from 'components/seats_calculator/consequences';
import SwitchToYearlyPlanConfirmModal from 'components/switch_to_yearly_plan_confirm_modal';
import StarMarkSvg from 'components/widgets/icons/star_mark_icon';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import 'components/payment_form/payment_form.scss';
import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils';
import {
TELEMETRY_CATEGORIES,
CloudLinks,
CloudProducts,
BillingSchemes,
ModalIdentifiers,
RecurringIntervals,
} from 'utils/constants';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import {t} from 'utils/i18n';
import {localizeMessage, getNextBillingDate, getBlankAddressWithCountry} from 'utils/utils';
import type {ModalData} from 'types/actions';
import type {BillingDetails} from 'types/cloud/sku';
import {areBillingDetailsValid} from 'types/cloud/sku';
import DelinquencyCard from './delinquency_card';
import IconMessage from './icon_message';
import ProcessPaymentSetup from './process_payment_setup';
import RenewalCard from './renewal_card';
import {findProductInDictionary, getSelectedProduct} from './utils';
import './purchase.scss';
let stripePromise: Promise<Stripe | null>;
export enum ButtonCustomiserClasses {
grayed = 'grayed',
active = 'active',
special = 'special',
}
export type ButtonDetails = {
action: () => void;
text: string;
disabled?: boolean;
customClass?: ButtonCustomiserClasses;
}
type CardProps = {
topColor?: string;
plan: string;
price?: string;
rate?: ReactNode;
buttonDetails: ButtonDetails;
planBriefing?: JSX.Element | null; // can be removed once Yearly Subscriptions are available
planLabel?: JSX.Element;
preButtonContent?: React.ReactNode;
afterButtonContent?: React.ReactNode;
}
type Props = {
customer: CloudCustomer | undefined;
show: boolean;
cwsMockMode: boolean;
products: Record<string, Product> | undefined;
yearlyProducts: Record<string, Product>;
contactSalesLink: string;
isFreeTrial: boolean;
productId: string | undefined;
currentTeam: Team;
intl: IntlShape;
theme: Theme;
isDelinquencyModal?: boolean;
isRenewalModal?: boolean;
invoices?: Invoice[];
isCloudDelinquencyGreaterThan90Days: boolean;
usersCount: number;
isComplianceBlocked: boolean;
contactSupportLink: string;
subscription: Subscription | undefined;
// callerCTA is information about the cta that opened this modal. This helps us provide a telemetry path
// showing information about how the modal was opened all the way to more CTAs within the modal itself
callerCTA?: string;
stripePublicKey: string;
actions: {
openModal: <P>(modalData: ModalData<P>) => void;
closeModal: () => void;
getCloudProducts: () => void;
completeStripeAddPaymentMethod: (
stripe: Stripe,
billingDetails: BillingDetails,
cwsMockMode: boolean
) => Promise<boolean | null>;
subscribeCloudSubscription: (
productId: string,
shippingAddress: Address,
seats?: number,
downgradeFeedback?: Feedback,
) => Promise<ActionResult>;
getClientConfig: () => void;
getCloudSubscription: () => void;
getInvoices: () => void;
};
};
type State = {
paymentInfoIsValid: boolean;
billingDetails: BillingDetails | null;
shippingAddress: Address | null;
cardInputComplete: boolean;
billingSameAsShipping: boolean;
processing: boolean;
editPaymentInfo: boolean;
currentProduct: Product | null | undefined;
selectedProduct: Product | null | undefined;
isUpgradeFromTrial: boolean;
buttonClickedInfo: string;
selectedProductPrice: string | null;
usersCount: number;
seats: Seats;
isSwitchingToAnnual: boolean;
}
export function Card(props: CardProps) {
const cardContent = (
<div className='PlanCard'>
{props.planLabel && props.planLabel}
<div
className='top'
style={{backgroundColor: props.topColor}}
/>
<div className='bottom'>
<div className='plan_price_rate_section'>
<h4>{props.plan}</h4>
<h1 className={props.plan === 'Enterprise' ? 'enterprise_price' : ''}>{`$${props.price}`}</h1>
<p>{props.rate}</p>
</div>
{props.planBriefing}
{props.preButtonContent}
<div>
<button
className={'plan_action_btn ' + props.buttonDetails.customClass}
disabled={props.buttonDetails.disabled}
onClick={props.buttonDetails.action}
>{props.buttonDetails.text}</button>
</div>
{props.afterButtonContent}
</div>
</div>
);
return (
cardContent
);
}
class PurchaseModal extends React.PureComponent<Props, State> {
modal = React.createRef();
public constructor(props: Props) {
super(props);
this.state = {
paymentInfoIsValid: false,
billingDetails: null,
billingSameAsShipping: true,
shippingAddress: null,
cardInputComplete: false,
processing: false,
editPaymentInfo: isEmpty(
props.customer?.payment_method &&
props.customer?.billing_address,
),
currentProduct: findProductInDictionary(
props.products,
props.productId,
),
selectedProduct: getSelectedProduct(
props.products!,
),
isUpgradeFromTrial: props.isFreeTrial,
buttonClickedInfo: '',
selectedProductPrice: getSelectedProduct(props.products!)?.price_per_seat.toString() || null,
usersCount: this.props.usersCount,
seats: {
quantity: this.props.usersCount.toString(),
error: this.props.usersCount.toString() === '0' ? errorInvalidNumber : null,
},
isSwitchingToAnnual: false,
};
}
async componentDidMount() {
if (isEmpty(this.state.currentProduct || this.state.selectedProduct)) {
await this.props.actions.getCloudProducts();
this.setState({
currentProduct: findProductInDictionary(this.props.products, this.props.productId),
selectedProduct: getSelectedProduct(this.props.products!),
selectedProductPrice: getSelectedProduct(this.props.products!)?.price_per_seat.toString() ?? null,
});
}
if (this.props.isDelinquencyModal) {
pageVisited(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'pageview_delinquency_cc_update',
);
this.props.actions.getInvoices();
} else {
pageVisited(TELEMETRY_CATEGORIES.CLOUD_PURCHASING, 'pageview_purchase');
}
this.props.actions.getClientConfig();
}
getDelinquencyTotalString = () => {
let totalOwed = 0;
this.props.invoices?.forEach((invoice) => {
totalOwed += invoice.total;
});
return `$${totalOwed / 100}`;
};
onPaymentInput = (billing: BillingDetails) => {
this.setState({billingDetails: billing}, this.isFormComplete);
};
isFormComplete = () => {
let paymentInfoIsValid = areBillingDetailsValid(this.state.billingDetails) && this.state.cardInputComplete;
if (!this.state.billingSameAsShipping) {
paymentInfoIsValid = paymentInfoIsValid && areShippingDetailsValid(this.state.shippingAddress);
}
this.setState({paymentInfoIsValid});
};
handleShippingSameAsBillingChange(value: boolean) {
this.setState({billingSameAsShipping: value}, this.isFormComplete);
}
onShippingInput = (address: Address) => {
this.setState({shippingAddress: {...this.state.shippingAddress, ...address}}, this.isFormComplete);
};
handleCardInputChange = (event: StripeCardElementChangeEvent) => {
this.setState({
paymentInfoIsValid:
areBillingDetailsValid(this.state.billingDetails) && event.complete,
});
this.setState({cardInputComplete: event.complete});
};
handleSubmitClick = async (callerInfo: string) => {
const update = {
selectedProduct: this.state.selectedProduct,
paymentInfoIsValid: false,
buttonClickedInfo: callerInfo,
processing: true,
} as unknown as Pick<State, keyof State>;
this.setState(update);
};
confirmSwitchToAnnual = () => {
const {customer} = this.props;
this.props.actions.openModal({
modalId: ModalIdentifiers.CONFIRM_SWITCH_TO_YEARLY,
dialogType: SwitchToYearlyPlanConfirmModal,
dialogProps: {
confirmSwitchToYearlyFunc: () => {
this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > confirm_switch_to_annual_modal > confirm_click');
this.setState({isSwitchingToAnnual: true});
},
contactSalesFunc: () => {
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'confirm_switch_to_annual_click_contact_sales',
);
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud');
},
},
});
};
setIsUpgradeFromTrialToFalse = () => {
this.setState({isUpgradeFromTrial: false});
};
openPricingModal = (callerInfo: string) => {
this.props.actions.openModal({
modalId: ModalIdentifiers.PRICING_MODAL,
dialogType: PricingModal,
dialogProps: {
callerCTA: callerInfo,
},
});
};
comparePlan = (
<button
className='ml-1'
onClick={() => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_compare_plans');
this.openPricingModal('purchase_modal_compare_plans_click');
}}
>
<FormattedMessage
id='cloud_subscribe.contact_support'
defaultMessage='Compare plans'
/>
</button>
);
contactSalesLink = (text: ReactNode) => {
return (
<ExternalLink
className='footer-text'
onClick={() => {
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'click_contact_sales',
);
}}
href={this.props.contactSalesLink}
location='purchase_modal'
>
{text}
</ExternalLink>
);
};
learnMoreLink = () => {
return (
<ExternalLink
className='footer-text'
onClick={() => {
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'learn_more_prorated_payment',
);
}}
href={CloudLinks.PRORATED_PAYMENT}
location='purchase_modal'
>
<FormattedMessage
defaultMessage={'Learn more'}
id={'admin.billing.subscription.LearnMore'}
/>
</ExternalLink>
);
};
editPaymentInfoHandler = () => {
this.setState((prevState: State) => {
return {
...prevState,
editPaymentInfo: !prevState.editPaymentInfo,
};
});
};
paymentFooterText = () => {
return (
<div className='plan_payment_commencement'>
<FormattedMessage
defaultMessage={'You\'ll be billed from: {beginDate}'}
id={'admin.billing.subscription.billedFrom'}
values={{
beginDate: getNextBillingDate(),
}}
/>
</div>
);
};
getPlanNameFromProductName = (productName: string): string => {
if (productName.length > 0) {
const [name] = productName.split(' ').slice(-1);
return name;
}
return productName;
};
getShippingAddressForProcessing = (): Address => {
if (this.state.billingSameAsShipping) {
return {
line1: this.state.billingDetails?.address || '',
line2: this.state.billingDetails?.address2 || '',
city: this.state.billingDetails?.city || '',
state: this.state.billingDetails?.state || '',
postal_code: this.state.billingDetails?.postalCode || '',
country: this.state.billingDetails?.country || '',
};
}
return this.state.shippingAddress as Address;
};
handleViewBreakdownClick = () => {
// If there is only one invoice, we can skip the summary and go straight to the invoice PDF preview for this singular invoice.
if (this.props.invoices?.length === 1) {
this.props.actions.openModal({
modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW,
dialogType: CloudInvoicePreview,
dialogProps: {
url: Client4.getInvoicePdfUrl(this.props.invoices[0].id),
},
});
} else {
this.props.actions.openModal({
modalId: ModalIdentifiers.BILLING_HISTORY,
dialogType: BillingHistoryModal,
dialogProps: {
invoices: this.props.invoices,
},
});
}
};
purchaseScreenCard = () => {
if (this.props.isDelinquencyModal) {
return (
<div className='RHS'>
<DelinquencyCard
topColor='#4A69AC'
price={this.getDelinquencyTotalString()}
buttonDetails={{
action: () => this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click'),
text: localizeMessage(
'cloud_delinquency.cc_modal.card.reactivate',
'Re-active',
),
customClass: this.state.paymentInfoIsValid ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.grayed,
disabled: !this.state.paymentInfoIsValid,
}}
onViewBreakdownClick={this.handleViewBreakdownClick}
isCloudDelinquencyGreaterThan90Days={this.props.isCloudDelinquencyGreaterThan90Days}
cost={parseInt(this.state.selectedProductPrice || '', 10) * this.props.usersCount}
users={this.props.usersCount}
/>
</div>
);
}
if (this.props.isRenewalModal) {
if (!this.props.subscription || !this.props.subscription.upcoming_invoice) {
return null;
}
const invoice = this.props.subscription?.upcoming_invoice;
const invoiceSummaryProps = buildInvoiceSummaryPropsFromLineItems(invoice?.line_items || []);
if (this.state.seats.quantity !== this.props.usersCount.toString()) {
// If the user has changed the number of seats, the stripe invoice won't yet reflect that new seat count
// We must look for the invoice item that occurs in the future (ie, the invoice item for the next billing period)
// And adjust the quantity, so the summary equates properly
invoiceSummaryProps.fullCharges = invoiceSummaryProps.fullCharges.map((lineitem: InvoiceLineItem) => {
if (new Date(lineitem.period_start * 1000) > new Date()) {
return {
...lineitem,
quantity: parseInt(this.state.seats.quantity, 10),
total: parseInt(this.state.seats.quantity, 10) * lineitem.price_per_unit,
};
}
return lineitem;
});
}
return (
<>
<RenewalCard
invoice={invoice}
product={this.state.selectedProduct || undefined}
seats={this.state.seats}
existingUsers={this.props.usersCount}
onSeatChange={(seats: Seats) => this.setState({seats})}
buttonDisabled={!this.state.paymentInfoIsValid}
onButtonClick={() => this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > renew_button_click')}
{...invoiceSummaryProps}
/>
</>
);
}
const showPlanLabel = this.state.selectedProduct?.sku === CloudProducts.PROFESSIONAL;
const {formatMessage, formatNumber} = this.props.intl;
const checkIsYearlyProfessionalProduct = (product: Product | null | undefined) => {
if (!product) {
return false;
}
return product.recurring_interval === RecurringIntervals.YEAR && product.sku === CloudProducts.PROFESSIONAL;
};
const yearlyProductMonthlyPrice = formatNumber(parseInt(this.state.selectedProductPrice || '0', 10) / 12, {maximumFractionDigits: 2});
const cardBtnText = formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'});
return (
<div className='RHS'>
<div
className={showPlanLabel ? 'plan_comparison show_label' : 'plan_comparison'}
>
{this.comparePlan}
</div>
<Card
topColor='#4A69AC'
plan={this.getPlanNameFromProductName(
this.state.selectedProduct ? this.state.selectedProduct.name : '',
)}
price={yearlyProductMonthlyPrice}
rate={formatMessage({id: 'pricing_modal.rate.seatPerMonth', defaultMessage: 'USD per seat/month {br}<b>(billed annually)</b>'}, {
br: <br/>,
b: (chunks: React.ReactNode | React.ReactNodeArray) => (
<span style={{fontSize: '14px'}}>
<b>{chunks}</b>
</span>
),
})}
planBriefing={<></>}
buttonDetails={{
action: () => {
this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click');
},
text: cardBtnText,
customClass:
!this.state.paymentInfoIsValid ||
this.state.selectedProduct?.billing_scheme === BillingSchemes.SALES_SERVE || this.state.seats.error !== null ||
(checkIsYearlyProfessionalProduct(this.state.currentProduct)) ? ButtonCustomiserClasses.grayed : ButtonCustomiserClasses.special,
disabled:
!this.state.paymentInfoIsValid ||
this.state.selectedProduct?.billing_scheme === BillingSchemes.SALES_SERVE ||
this.state.seats.error !== null ||
(checkIsYearlyProfessionalProduct(this.state.currentProduct)),
}}
planLabel={
showPlanLabel ? (
<PlanLabel
text={formatMessage({
id: 'pricing_modal.planLabel.mostPopular',
defaultMessage: 'MOST POPULAR',
})}
bgColor='var(--title-color-indigo-500)'
color='var(--button-color)'
firstSvg={<StarMarkSvg/>}
secondSvg={<StarMarkSvg/>}
/>
) : undefined
}
preButtonContent={(
<SeatsCalculator
price={parseInt(yearlyProductMonthlyPrice, 10)}
seats={this.state.seats}
existingUsers={this.props.usersCount}
isCloud={true}
onChange={(seats: Seats) => {
this.setState({seats});
}}
/>
)}
afterButtonContent={
<Consequences
isCloud={true}
licenseAgreementBtnText={cardBtnText}
/>
}
/>
</div>
);
};
purchaseScreen = () => {
const title = (
<FormattedMessage
defaultMessage={'Provide your payment details'}
id={'admin.billing.subscription.providePaymentDetails'}
/>
);
let initialBillingDetails;
let validBillingDetails = false;
if (this.props.customer?.billing_address && this.props.customer?.payment_method) {
initialBillingDetails = {
address: this.props.customer?.billing_address.line1,
address2: this.props.customer?.billing_address.line2,
city: this.props.customer?.billing_address.city,
state: this.props.customer?.billing_address.state,
country: this.props.customer?.billing_address.country,
postalCode: this.props.customer?.billing_address.postal_code,
name: this.props.customer?.payment_method.name,
} as BillingDetails;
validBillingDetails = areBillingDetailsValid(initialBillingDetails);
}
return (
<div className={classnames('PurchaseModal__purchase-body', {processing: this.state.processing})}>
<div className='LHS'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
width={267}
height={227}
/>
<div className='footer-text'>{'Questions?'}</div>
{this.contactSalesLink('Contact Sales')}
</div>
<div className='central-panel'>
{this.state.editPaymentInfo || !validBillingDetails ? (
<PaymentForm
className='normal-text'
onInputChange={this.onPaymentInput}
onCardInputChange={this.handleCardInputChange}
initialBillingDetails={initialBillingDetails}
theme={this.props.theme}
customer={this.props.customer}
/>
) : (
<div className='PaymentDetails'>
<div className='title'>
<FormattedMessage
defaultMessage='Your saved payment details'
id='admin.billing.purchaseModal.savedPaymentDetailsTitle'
/>
</div>
<PaymentDetails>
<button
onClick={this.editPaymentInfoHandler}
className='editPaymentButton'
>
<FormattedMessage
defaultMessage='Edit'
id='admin.billing.purchaseModal.editPaymentInfoButton'
/>
</button>
</PaymentDetails>
</div>
)}
<div className='shipping-address-section'>
<input
id='address-same-than-billing-address'
className='Form-checkbox-input'
name='terms'
type='checkbox'
checked={this.state.billingSameAsShipping}
onChange={() =>
this.handleShippingSameAsBillingChange(
!this.state.billingSameAsShipping,
)
}
/>
<span className='Form-checkbox-label'>
<button
onClick={() =>
this.handleShippingSameAsBillingChange(
!this.state.billingSameAsShipping,
)
}
type='button'
className='no-style'
>
<span className='billing_address_btn_text'>
{this.props.intl.formatMessage({
id: 'admin.billing.subscription.complianceScreenShippingSameAsBilling',
defaultMessage:
'My shipping address is the same as my billing address',
})}
</span>
</button>
</span>
</div>
{!this.state.billingSameAsShipping && (
<AddressForm
onAddressChange={this.onShippingInput}
onBlur={() => {}}
title={{
id: 'payment_form.shipping_address',
defaultMessage: 'Shipping Address',
}}
formId={'shippingAddress'}
// Setup the initial country based on their billing country, or USA.
address={
this.state.shippingAddress ||
getBlankAddressWithCountry(
this.state.billingDetails?.country || 'US',
)
}
/>
)}
</div>
{this.purchaseScreenCard()}
</div>
);
};
render() {
if (this.props.isComplianceBlocked) {
return (
<RootPortal>
<FullScreenModal
show={Boolean(this.props.show)}
onClose={() => {
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'click_close_purchasing_screen',
);
this.props.actions.getCloudSubscription();
this.props.actions.closeModal();
}}
ref={this.modal}
ariaLabelledBy='purchase_modal_title'
>
<div className='PurchaseModal'>
<IconMessage
title={t(
'admin.billing.subscription.complianceScreenFailed.title',
)}
icon={
<ComplianceScreenFailedSvg
width={321}
height={246}
/>
}
buttonText={t(
'admin.billing.subscription.complianceScreenFailed.button',
)}
buttonHandler={() =>
this.props.actions.closeModal()
}
linkText={t(
'admin.billing.subscription.privateCloudCard.contactSupport',
)}
linkURL={this.props.contactSupportLink}
className={'failed'}
/>
</div>
</FullScreenModal>
</RootPortal>
);
}
if (!stripePromise) {
stripePromise = loadStripe(this.props.stripePublicKey);
}
return (
<Elements
options={{fonts: [{cssSrc: STRIPE_CSS_SRC}]}}
stripe={stripePromise}
>
<RootPortal>
<FullScreenModal
show={Boolean(this.props.show)}
onClose={() => {
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'click_close_purchasing_screen',
);
this.props.actions.getCloudSubscription();
this.props.actions.closeModal();
}}
ref={this.modal}
ariaLabelledBy='purchase_modal_title'
overrideTargetEvent={false}
>
<div className='PurchaseModal'>
{this.state.processing ? (
<div>
<ProcessPaymentSetup
stripe={stripePromise}
billingDetails={
this.state.billingDetails
}
shippingAddress={
this.getShippingAddressForProcessing()
}
addPaymentMethod={
this.props.actions.
completeStripeAddPaymentMethod
}
subscribeCloudSubscription={
this.props.actions.
subscribeCloudSubscription
}
cwsMockMode={this.props.cwsMockMode}
onClose={() => {
this.props.actions.getCloudSubscription();
this.props.actions.closeModal();
}}
onBack={() => {
this.setState({
processing: false,
});
}}
contactSupportLink={
this.props.contactSupportLink
}
currentTeam={this.props.currentTeam}
onSuccess={() => {
// Success only happens if all invoices have been paid.
if (this.props.isDelinquencyModal) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'paid_arrears');
}
}}
selectedProduct={this.state.selectedProduct}
currentProduct={this.state.currentProduct}
isProratedPayment={(!this.props.isFreeTrial && this.state.currentProduct?.billing_scheme === BillingSchemes.FLAT_FEE) &&
this.state.selectedProduct?.billing_scheme === BillingSchemes.PER_SEAT}
setIsUpgradeFromTrialToFalse={this.setIsUpgradeFromTrialToFalse}
isUpgradeFromTrial={this.state.isUpgradeFromTrial}
isSwitchingToAnnual={this.state.isSwitchingToAnnual}
telemetryProps={{
callerInfo:
this.state.buttonClickedInfo,
}}
usersCount={parseInt(this.state.seats.quantity, 10)}
/>
</div>
) : null}
{this.purchaseScreen()}
<div className='background-svg'>
<BackgroundSvg/>
</div>
</div>
</FullScreenModal>
</RootPortal>
</Elements>
);
}
}
export default injectIntl(PurchaseModal);

View File

@ -1,257 +0,0 @@
.PurchaseModal {
.RenewalRHS {
position: sticky;
display: flex;
width: 25%;
flex-direction: column;
padding-top: 80px;
.RenewalCard {
max-width: 353px;
padding: 28px 32px;
border: 1px solid var(--light-8-center-channel-text, rgba(61, 60, 64, 0.08));
border-radius: 4px;
background: var(--light-center-channel-bg, #fff);
box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08);
.SeatsCalculator {
padding: 0;
.SeatsCalculator__seats-item.SeatsCalculator__seats-item--input {
padding: 0;
}
}
.RenewalSummary {
width: 332px;
padding: 28px 32px;
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
border-radius: 4px;
margin-left: 20px;
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
}
.RenewalSummary__noBillingHistory {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.RenewalSummary__noBillingHistory-title {
margin-top: 32px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.RenewalSummary__noBillingHistory-message {
margin-top: 8px;
color: var(--sys-center-channel-color);
font-size: 14px;
line-height: 20px;
}
.RenewalSummary__noBillingHistory-link {
margin-top: 16px;
margin-bottom: 24px;
color: var(--sys-button-bg);
font-size: 12px;
font-weight: 600;
}
.RenewalSummary__lastInvoice {
color: var(--sys-center-channel-color);
hr {
border-color: rgba(var(--sys-center-channel-color-rgb), 0.32);
margin: 12px 0;
}
}
.RenewalSummary__lastInvoice-header {
display: flex;
align-items: center;
}
.RenewalSummary__lastInvoice-headerTitle {
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.BillingSummary__lastInvoice-headerStatus {
display: flex;
align-items: center;
margin-left: auto;
font-size: 12px;
font-weight: 600;
line-height: 16px;
&.paid {
color: var(--sys-online-indicator);
}
&.failed {
color: var(--sys-error-text);
}
&.pending {
color: #f58b00;
}
svg {
width: 16px;
height: 16px;
margin-left: 4px;
font-size: 14.4px;
&::before {
margin: 0 auto;
}
}
span {
margin-left: 4px;
}
}
.RenewalSummary__upcomingInvoice-due-date {
margin-top: 8px;
color: var(--light-72-center-channel-text, rgba(61, 60, 64, 0.72));
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.RenewalSummary__lastInvoice-productName {
margin-top: 32px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.RenewalSummary__upcomingInvoice-charge {
display: flex;
margin: 12px 0;
color: var(--center-channel-color);
font-size: 12px;
font-weight: 400;
line-height: 20px;
&.total {
font-size: 14px;
font-weight: 600;
}
}
.RenewalSummary__upcomingInvoice-hasMoreItems {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 14px;
font-weight: 400;
.RenewalSummary__upcomingInvoice-chargeDescription {
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
}
.RenewalSummary__upcomingInvoice-ellipses {
display: flex;
align-items: center;
align-self: stretch;
justify-content: center;
fill: #d9d9d9;
}
.RenewalSummary__lastInvoice-chargeAmount {
margin-left: auto;
}
.RenewalSummary__lastInvoice-partialCharges {
color: rgba(var(--sys-center-channel-color-rgb), 0.56);
font-size: 12px;
line-height: 16px;
& + .RenewalSummary__lastInvoice-charge {
margin-top: 4px;
}
}
.RenewalSummary__lastInvoice-download {
margin-top: 32px;
margin-bottom: 16px;
}
.RenewalSummary__upcomingInvoice-renew-button {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
padding: 11px 20px;
border-radius: 4px;
background: var(--sys-button-bg);
color: var(--sys-button-color);
&:hover:not(:disabled) {
background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.16), rgba(var(--sys-center-channel-color-rgb), 0.16)), var(--sys-button-bg);
color: var(--sys-button-color);
cursor: pointer;
text-decoration: none;
}
&:disabled {
background: rgba(var(--sys-center-channel-color-rgb), 0.08);
color: rgba(var(--sys-center-channel-color-rgb), 0.32);
cursor: not-allowed;
}
>span {
margin-left: 6px;
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
}
.RenewalSummary__upcomingInvoice-viewInvoiceLink {
display: block;
width: 100%;
height: 16px;
margin-bottom: 12px;
background: none;
color: var(--link-color, #1c58d9);
font-size: 12px;
font-weight: 600;
line-height: 9.5px;
text-align: center;
}
.RenewalSummary__upcomingInvoice-viewInvoice {
display: block;
width: 100%;
background: none;
color: var(--link-color, #1c58d9);
font-size: 12px;
font-weight: 600;
line-height: 9.5px;
text-align: center;
}
.RenewalSummary__disclaimer {
margin-top: 16px;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
}
}
}

View File

@ -1,311 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, FormattedNumber, FormattedDate, defineMessages} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud';
import {Client4} from 'mattermost-redux/client';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import {getPaymentStatus} from 'components/admin_console/billing/billing_summary/billing_summary';
import CloudInvoicePreview from 'components/cloud_invoice_preview';
import type {Seats} from 'components/seats_calculator';
import SeatsCalculator from 'components/seats_calculator';
import EllipsisHorizontalIcon from 'components/widgets/icons/ellipsis_h_icon';
import WithTooltip from 'components/with_tooltip';
import {BillingSchemes, ModalIdentifiers, TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants';
import './renewal_card.scss';
const messages = defineMessages({
partialChargesTooltipTitle: {
id: 'admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges',
defaultMessage: 'What are partial charges?',
},
partialChargesTooltipText: {
id: 'admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges.message',
defaultMessage: 'Users who have not been enabled for the full duration of the month are charged at a prorated monthly rate.',
},
});
type RenewalCardProps = {
invoice: Invoice;
product?: Product;
hasMore?: number;
fullCharges: InvoiceLineItem[];
partialCharges: InvoiceLineItem[];
seats: Seats;
existingUsers: number;
onSeatChange: (seats: Seats) => void;
buttonDisabled?: boolean;
onButtonClick?: () => void;
};
export default function RenewalCard({invoice, product, hasMore, fullCharges, partialCharges, seats, onSeatChange, existingUsers, buttonDisabled, onButtonClick}: RenewalCardProps) {
const dispatch = useDispatch();
const openInvoicePreview = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW,
dialogType: CloudInvoicePreview,
dialogProps: {
url: Client4.getInvoicePdfUrl(invoice.id),
},
}),
);
};
const seeHowBillingWorks = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_see_how_billing_works',
);
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
};
return (
<div className='RenewalRHS'>
<div className='RenewalCard'>
<div className='RenewalSummary__lastInvoice-header'>
<div className='RenewalSummary__lastInvoice-headerTitle'>
<FormattedMessage
id='admin.billing.subscription.invoice.next'
defaultMessage='Next Invoice'
/>
</div>
{getPaymentStatus(invoice.status)}
</div>
<div className='RenewalSummary__upcomingInvoice-due-date'>
<FormattedMessage
id={'cloud.renewal.tobepaid'}
defaultMessage={'To be paid on {date}'}
values={{
date: (
<FormattedDate
value={new Date(invoice.period_start)}
month='short'
year='numeric'
day='numeric'
timeZone='UTC'
/>
),
}}
/>
</div>
<div className='RenewalSummary__lastInvoice-productName'>
{product?.name}
</div>
<hr style={{marginTop: '12px'}}/>
<SeatsCalculator
price={product!.price_per_seat}
seats={seats}
onChange={(seats: Seats) => {
onSeatChange(seats);
}}
isCloud={true}
existingUsers={existingUsers}
excludeTotal={true}
/>
{fullCharges.map((charge: any) => (
<div
key={charge.price_id}
className='RenewalSummary__upcomingInvoice-charge'
>
<div className='RenewalSummary__upcomingInvoice-chargeDescription'>
<>
<FormattedNumber
value={charge.price_per_unit / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.seatCount'
defaultMessage=' x {seats} seats'
values={{seats: charge.quantity}}
/>
{(' ')}
{'('}
<FormattedDate
value={new Date(charge.period_start * 1000)}
month='numeric'
year='numeric'
day='numeric'
timeZone='UTC'
/>
{')'}
</>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={charge.total / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
))}
{Boolean(hasMore) && (
<div
className='RenewalSummary__upcomingInvoice-hasMoreItems'
>
<div
onClick={openInvoicePreview}
className='RenewalSummary__upcomingInvoice-chargeDescription'
>
{product?.billing_scheme === BillingSchemes.FLAT_FEE ? (
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee'
defaultMessage='Monthly Flat Fee'
/>
) : (
<>
<FormattedMessage
id='cloud.renewal.andMoreItems'
defaultMessage='+ {count} more items'
values={{count: hasMore}}
/>
</>
)}
</div>
</div>
)}
{Boolean(partialCharges.length) && (
<>
<div className='RenewalSummary__lastInvoice-partialCharges'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges'
defaultMessage='Partial charges'
/>
<WithTooltip
id='BillingSubscriptions__seatOverageTooltip'
title={messages.partialChargesTooltipTitle}
hint={messages.partialChargesTooltipText}
placement='bottom'
>
<i className='icon-information-outline'/>
</WithTooltip>
</div>
{partialCharges.map((charge: any) => (
<div
key={charge.price_id}
className='RenewalSummary__lastInvoice-charge'
>
<div className='RenewalSummary__lastInvoice-chargeDescription'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.seatCountPartial'
defaultMessage='{seats} seats'
values={{seats: charge.quantity}}
/>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={charge.total / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
))}
</>
)}
{Boolean(hasMore) && (
<div className='RenewalSummary__upcomingInvoice-ellipses'>
<EllipsisHorizontalIcon width={'40px'}/>
</div>
)}
{Boolean(invoice.tax) && (
<div className='RenewalSummary__lastInvoice-charge'>
<div className='RenewalSummary__lastInvoice-chargeDescription'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.taxes'
defaultMessage='Taxes'
/>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={invoice.tax / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
)}
<button
onClick={openInvoicePreview}
className='RenewalSummary__upcomingInvoice-viewInvoiceLink'
>
<FormattedMessage
id='cloud.renewal.viewInvoice'
defaultMessage='View Invoice'
/>
</button>
<hr style={{marginTop: '0'}}/>
<div className='RenewalSummary__upcomingInvoice-charge total'>
<div className='RenewalSummary__upcomingInvoice-chargeDescription'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.total'
defaultMessage='Total'
/>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={fullCharges.reduce((sum: number, item) => sum + item.total, 0) / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
<button
onClick={onButtonClick}
className='RenewalSummary__upcomingInvoice-renew-button'
disabled={Boolean(buttonDisabled) || Boolean(seats.error)}
>
<FormattedMessage
id='cloud.renewal.renew'
defaultMessage='Renew'
/>
</button>
<div className='RenewalSummary__disclaimer'>
<FormattedMessage
defaultMessage={
'Your bill is calculated at the end of the billing cycle based on the number of enabled users. {seeHowBillingWorks}'
}
id={
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
}
values={{
seeHowBillingWorks: (
<a onClick={seeHowBillingWorks}>
<FormattedMessage
defaultMessage={'See how billing works.'}
id={
'admin.billing.subscription.howItWorks'
}
/>
</a>
),
}}
/>
</div>
</div>
</div>
);
}

View File

@ -1,42 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Product} from '@mattermost/types/cloud';
import {CloudProducts, RecurringIntervals} from 'utils/constants';
/**
*
* @param products Record<string, Product> | undefined - the list of current cloud products
* @param productId String - a valid product id used to find a particular product in the dictionary
* @param productSku String - the sku value of the product of type either cloud-starter | cloud-professional | cloud-enterprise
* @returns Product
*/
export function findProductInDictionary(products: Record<string, Product> | undefined, productId?: string | null, productSku?: string, productRecurringInterval?: string): Product | null {
if (!products) {
return null;
}
const keys = Object.keys(products);
if (!keys.length) {
return null;
}
if (!productId && !productSku) {
return products[keys[0]];
}
let currentProduct = products[keys[0]];
if (keys.length > 1) {
// here find the product by the provided id or name, otherwise return the one with Professional in the name
keys.forEach((key) => {
if (productId && products[key].id === productId) {
currentProduct = products[key];
} else if (productSku && products[key].sku === productSku && products[key].recurring_interval === productRecurringInterval) {
currentProduct = products[key];
}
});
}
return currentProduct;
}
export function getSelectedProduct(yearlyProducts: Record<string, Product>) {
return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR);
}

View File

@ -28,7 +28,6 @@ import {makeAsyncComponent} from 'components/async_load';
import CloudEffects from 'components/cloud_effects';
import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider';
import OpenPluginInstallPost from 'components/custom_open_plugin_install_post_renderer';
import OpenPricingModalPost from 'components/custom_open_pricing_modal_post_renderer';
import GlobalHeader from 'components/global_header/global_header';
import {HFRoute} from 'components/header_footer_route/header_footer_route';
import {HFTRoute, LoggedInHFTRoute} from 'components/header_footer_template_route';
@ -412,7 +411,6 @@ export default class Root extends React.PureComponent<Props, State> {
this.initiateMeRequests();
// See figma design on issue https://mattermost.atlassian.net/browse/MM-43649
this.props.actions.registerCustomPostRenderer('custom_up_notification', OpenPricingModalPost, 'upgrade_post_message_renderer');
this.props.actions.registerCustomPostRenderer('custom_pl_notification', OpenPluginInstallPost, 'plugin_install_post_message_renderer');
measurePageLoadTelemetry();

View File

@ -1,115 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
import CountrySelector from 'components/payment_form/country_selector';
import StateSelector from 'components/payment_form/state_selector';
import Input from 'components/widgets/inputs/input/input';
interface Props {
type: 'shipping' | 'billing';
testPrefix?: string;
country: string;
changeCountry: (option: {value: string}) => void;
address: string;
changeAddress: (e: React.ChangeEvent<HTMLInputElement>) => void;
address2: string;
changeAddress2: (e: React.ChangeEvent<HTMLInputElement>) => void;
city: string;
changeCity: (e: React.ChangeEvent<HTMLInputElement>) => void;
state: string;
changeState: (postalCode: string) => void;
postalCode: string;
changePostalCode: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function Address(props: Props) {
const testPrefix = props.testPrefix || 'selfHostedPurchase';
const intl = useIntl();
let countrySelectorId = `${testPrefix}CountrySelector`;
let stateSelectorId = `${testPrefix}StateSelector`;
if (props.type === 'shipping') {
countrySelectorId += '_Shipping';
stateSelectorId += '_Shipping';
}
return (
<>
<div className={classNames({'third-dropdown-sibling-wrapper': props.type === 'shipping'})}>
<CountrySelector
testId={countrySelectorId}
onChange={props.changeCountry}
value={props.country}
/>
</div>
<div className='form-row'>
<Input
name='address'
type='text'
value={props.address}
onChange={props.changeAddress}
placeholder={intl.formatMessage({
id: 'payment_form.address',
defaultMessage: 'Address',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='address2'
type='text'
value={props.address2}
onChange={props.changeAddress2}
placeholder={intl.formatMessage({
id: 'payment_form.address_2',
defaultMessage: 'Address 2',
})}
/>
</div>
<div className='form-row'>
<Input
name='city'
type='text'
value={props.city}
onChange={props.changeCity}
placeholder={intl.formatMessage({
id: 'payment_form.city',
defaultMessage: 'City',
})}
required={true}
/>
</div>
<div className='form-row'>
<div className={classNames('form-row-third-1', {'second-dropdown-sibling-wrapper': props.type === 'billing', 'fourth-dropdown-sibling-wrapper': props.type === 'shipping'})}>
<StateSelector
testId={stateSelectorId}
country={props.country}
state={props.state}
onChange={props.changeState}
/>
</div>
<div className='form-row-third-2'>
<Input
name='postalCode'
type='text'
value={props.postalCode}
onChange={props.changePostalCode}
placeholder={intl.formatMessage({
id: 'payment_form.zipcode',
defaultMessage: 'Zip/Postal Code',
})}
required={true}
/>
</div>
</div>
</>
);
}

View File

@ -1,5 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const STORAGE_KEY_PURCHASE_IN_PROGRESS = 'PURCHASE_IN_PROGRESS';
export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS';

View File

@ -1,34 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import ExternalLink from 'components/external_link';
import {
TELEMETRY_CATEGORIES,
} from 'utils/constants';
export default function ContactSalesLink() {
const [, contactSalesLink] = useOpenSalesLink();
const intl = useIntl();
return (
<ExternalLink
className='footer-text'
onClick={() => {
trackEvent(
TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING,
'click_contact_sales',
);
}}
href={contactSalesLink}
location='contact_sales_link'
>
{intl.formatMessage({id: 'self_hosted_signup.contact_sales', defaultMessage: 'Contact Sales'})}
</ExternalLink>
);
}

View File

@ -1,3 +0,0 @@
.self_hosted_expansion_failed {
margin-top: 163px;
}

View File

@ -1,89 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import {TELEMETRY_CATEGORIES} from 'utils/constants';
import './error_page.scss';
interface Props {
canRetry: boolean;
tryAgain: () => void;
}
export default function SelfHostedExpansionErrorPage(props: Props) {
const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error');
const formattedTitle = (
<FormattedMessage
id='admin.billing.subscription.paymentVerificationFailed'
defaultMessage='Sorry, the payment verification failed'
/>
);
let formattedButtonText = (
<FormattedMessage
id='self_hosted_expansion.try_again'
defaultMessage='Try again'
/>
);
if (!props.canRetry) {
formattedButtonText = (
<FormattedMessage
id='self_hosted_expansion.close'
defaultMessage='Close'
/>
);
}
const formattedSubtitle = (
<FormattedMessage
id='self_hosted_expansion.paymentFailed'
defaultMessage='Payment failed. Please try again or contact support.'
/>
);
const tertiaryButtonText = (
<FormattedMessage
id='self_hosted_expansion.contact_support'
defaultMessage={'Contact Support'}
/>
);
const icon = (
<PaymentFailedSvg
width={444}
height={313}
/>
);
return (
<div className='self_hosted_expansion_failed'>
<IconMessage
formattedTitle={formattedTitle}
formattedSubtitle={formattedSubtitle}
icon={icon}
error={true}
formattedButtonText={formattedButtonText}
buttonHandler={() => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'failure_try_again_clicked');
props.tryAgain();
}}
formattedTertiaryButonText={tertiaryButtonText}
tertiaryButtonHandler={() => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'failure_contact_support_clicked');
window.open(contactSupportLink, '_blank', 'noreferrer');
}}
/>
</div>
);
}

View File

@ -1,131 +0,0 @@
.SelfHostedExpansionRHSCard {
display: flex;
width: 280px;
flex-direction: column;
&__Content {
padding: 24px;
border: 1px solid;
border-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16);
border-radius: 4px;
}
&__RHSCardTitle {
display: block;
margin-bottom: 12px;
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
font-size: 14px;
font-weight: 600;
text-align: center;
text-transform: capitalize;
}
.seatsInput {
width: 73px;
margin-left: auto;
font-size: 14px;
font-weight: 400;
input[type="number"] {
text-align: right;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
margin: 0;
-webkit-appearance: none;
}
}
&__PlanDetails {
display: flex;
flex-direction: column;
text-align: center;
.planName {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
font-size: 20px;
font-weight: 400;
text-transform: capitalize;
}
.usage {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
font-size: 12px;
font-weight: 600;
:first-child {
text-transform: uppercase;
}
}
}
hr {
width: 90%;
height: 2px;
background-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16);
}
&__seatInput,
&__cost_breakdown {
display: grid;
font-weight: 400;
gap: 10px;
grid-template-columns: repeat(2, 1fr);
.costPerUser > span:first-child {
font-size: 14px;
}
.costPerUser > span:last-child {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
font-size: 12px;
}
.totalCostWarning {
width: 141px;
}
.totalCostWarning > span:first-child {
color: var(--sys-denim-center-channel-text);
font-size: 14px;
font-weight: 700;
}
.totalCostWarning > span:last-child {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
font-size: 12px;
}
.costAmount {
width: 100%;
margin-right: 0;
margin-left: auto;
font-weight: 700;
}
}
&__AddSeatsWarning {
display: block;
width: 100%;
height: 35px;
margin-bottom: 15px;
color: var(--dnd-indicator);
font-size: 12px;
font-weight: 600;
text-align: right;
}
&__CompletePurchaseButton {
width: 100%;
border-radius: 4px;
margin-top: 10px;
margin-bottom: 10px;
}
&__ChargedTodayDisclaimer {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
font-size: 12px;
font-weight: 400;
}
}

View File

@ -1,244 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts';
import ExternalLink from 'components/external_link';
import {OutlinedInput} from 'components/outlined_input';
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
import {DocLinks} from 'utils/constants';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import './expansion_card.scss';
const MONTHS_IN_YEAR = 12;
const MAX_TRANSACTION_VALUE = 1_000_000 - 1;
interface Props {
canSubmit: boolean;
licensedSeats: number;
minimumSeats: number;
submit: () => void;
updateSeats: (seats: number) => void;
}
export default function SelfHostedExpansionCard(props: Props) {
const intl = useIntl();
const license = useSelector(getLicense);
const startsAt = moment(parseInt(license.StartsAt, 10)).format('MMM. D, YYYY');
const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY');
const [additionalSeats, setAdditionalSeats] = useState(props.minimumSeats);
const [overMaxSeats, setOverMaxSeats] = useState(false);
const licenseExpiry = new Date(parseInt(license.ExpiresAt, 10));
const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats) || additionalSeats < props.minimumSeats;
const [products] = useGetSelfHostedProducts();
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
const costPerMonth = currentProduct?.price_per_seat || 0;
const getMonthsUntilExpiry = () => {
const now = new Date();
return (licenseExpiry.getMonth() - now.getMonth()) + (MONTHS_IN_YEAR * (licenseExpiry.getFullYear() - now.getFullYear()));
};
const getCostPerUser = () => {
const monthsUntilExpiry = getMonthsUntilExpiry();
return costPerMonth * monthsUntilExpiry;
};
const getPaymentTotal = () => {
if (isNaN(additionalSeats)) {
return 0;
}
const monthsUntilExpiry = getMonthsUntilExpiry();
return additionalSeats * costPerMonth * monthsUntilExpiry;
};
// Finds the maximum number of additional seats that is possible, taking into account
// the stripe transaction limit. The maximum number of seats will follow the formula:
// (StripeTransaction Limit - (current_seats * yearly_price_per_seat)) / yearly_price_per_seat
const getMaximumAdditionalSeats = () => {
if (currentProduct === null) {
return 0;
}
const currentPaymentPrice = costPerMonth * props.licensedSeats * 12;
const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice;
const remainingSeats = Math.floor(remainingTransactionLimit / (costPerMonth * 12));
return Math.max(0, remainingSeats);
};
const maxAdditionalSeats = getMaximumAdditionalSeats();
const handleNewSeatsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const requestedSeats = parseInt(e.target.value, 10);
if (!isNaN(requestedSeats) && requestedSeats <= 0) {
e.preventDefault();
return;
}
setOverMaxSeats(false);
const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats;
setOverMaxSeats(overMaxAdditionalSeats);
const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats;
setAdditionalSeats(finalSeatCount);
props.updateSeats(finalSeatCount);
};
const formatCurrency = (value: number) => {
return intl.formatNumber(value, {style: 'currency', currency: 'USD'});
};
return (
<div className='SelfHostedExpansionRHSCard'>
<div className='SelfHostedExpansionRHSCard__RHSCardTitle'>
<FormattedMessage
id='self_hosted_expansion_rhs_license_summary_title'
defaultMessage='License Summary'
/>
</div>
<div className='SelfHostedExpansionRHSCard__Content'>
<div className='SelfHostedExpansionRHSCard__PlanDetails'>
<span className='planName'>{license.SkuShortName}</span>
<div className='usage'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_license_date'
defaultMessage='{startsAt} - {endsAt}'
values={{
startsAt,
endsAt,
}}
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_licensed_seats'
defaultMessage='{licensedSeats} LICENSES SEATS'
values={{
licensedSeats: props.licensedSeats,
}}
/>
</div>
</div>
<hr/>
<div className='SelfHostedExpansionRHSCard__seatInput'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_add_new_seats'
defaultMessage='Add new seats'
/>
<OutlinedInput
data-testid='seatsInput'
className='seatsInput'
size='small'
type='number'
value={additionalSeats}
onChange={handleNewSeatsInputChange}
disabled={maxAdditionalSeats === 0}
/>
</div>
<div className='SelfHostedExpansionRHSCard__AddSeatsWarning'>
{invalidAdditionalSeats && !overMaxSeats && isNaN(additionalSeats) &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_must_add_seats_warning'
defaultMessage='{warningIcon} You must add a seat to continue'
values={{
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
{invalidAdditionalSeats && additionalSeats < props.minimumSeats &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_must_purchase_enough_seats'
defaultMessage='{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license'
values={{
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
minimumSeats: props.minimumSeats,
}}
/>
}
{overMaxSeats && maxAdditionalSeats > 0 &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_maximum_seats_warning'
defaultMessage='{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats'
values={{
maxAdditionalSeats,
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
</div>
<div className='SelfHostedExpansionRHSCard__cost_breakdown'>
<div className='costPerUser'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_cost_per_user_title'
defaultMessage='Cost per user'
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_cost_per_user_breakdown'
/* eslint-disable no-template-curly-in-string*/
defaultMessage='{costPerUser} x {monthsUntilExpiry} months'
values={{
costPerUser: formatCurrency(costPerMonth),
monthsUntilExpiry: getMonthsUntilExpiry(),
}}
/>
</div>
<div className='costAmount'>
<span>{formatCurrency(getCostPerUser())}</span>
</div>
<div className='totalCostWarning'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_total_title'
defaultMessage='Total'
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_total_prorated_warning'
defaultMessage='The total will be prorated'
/>
</div>
<span className='totalCostAmount'>
<span>{formatCurrency(getPaymentTotal()) }</span>
</span>
</div>
<button
className='btn btn-primary SelfHostedExpansionRHSCard__CompletePurchaseButton'
disabled={!props.canSubmit || maxAdditionalSeats === 0 || invalidAdditionalSeats}
onClick={props.submit}
>
<FormattedMessage
id='self_hosted_expansion_rhs_complete_button'
defaultMessage='Complete purchase'
/>
</button>
<div className='SelfHostedExpansionRHSCard__ChargedTodayDisclaimer'>
<FormattedMessage
id='self_hosted_expansion_rhs_credit_card_charge_today_warning'
defaultMessage='Your credit card will be charged today.<see_how_billing_works>See how billing works.</see_how_billing_works>'
values={{
see_how_billing_works: (text: string) => (
<>
<br/>
<ExternalLink
href={DocLinks.SELF_HOSTED_BILLING}
>
{text}
</ExternalLink>
</>
),
}}
/>
</div>
</div>
</div>
);
}

View File

@ -1,505 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import type {SelfHostedSignupForm} from '@mattermost/types/hosted_customer';
import type {DeepPartial} from '@mattermost/types/utilities';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {
fireEvent,
renderWithContext,
screen,
waitFor,
} from 'tests/react_testing_utils';
import {SelfHostedProducts, ModalIdentifiers, RecurringIntervals} from 'utils/constants';
import {TestHelper as TH} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import SelfHostedExpansionModal, {makeInitialState, canSubmit} from './';
import type {FormState} from './';
interface MockCardInputProps {
onCardInputChange: (event: {complete: boolean}) => void;
forwardedRef: React.MutableRefObject<any>;
}
// number borrowed from stripe
const successCardNumber = '4242424242424242';
function MockCardInput(props: MockCardInputProps) {
props.forwardedRef.current = {
getCard: () => ({}),
};
return (
<input
placeholder='Card number'
type='text'
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === successCardNumber) {
props.onCardInputChange({complete: true});
}
}}
/>
);
}
jest.mock('components/payment_form/card_input', () => {
const original = jest.requireActual('components/payment_form/card_input');
return {
...original,
__esModule: true,
default: MockCardInput,
};
});
jest.mock('components/self_hosted_purchases/stripe_provider', () => {
return function(props: {children: React.ReactNode | React.ReactNodeArray}) {
return props.children;
};
});
jest.mock('components/common/hooks/useLoadStripe', () => {
return function() {
return {current: {
stripe: {},
}};
};
});
const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT;
const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE;
const failOrg = 'failorg';
const existingUsers = 10;
const mockProfessionalProduct = TH.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
recurring_interval: RecurringIntervals.MONTH,
});
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
return {
__esModule: true,
...original,
Client4: {
...original.Client4,
pageVisited: jest.fn(),
setAcceptLanguage: jest.fn(),
trackEvent: jest.fn(),
createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => {
if (form.organization === failOrg) {
throw new Error('error creating customer');
}
return Promise.resolve({
progress: mockCreatedIntent,
});
},
confirmSelfHostedExpansion: () => Promise.resolve({
progress: mockCreatedLicense,
license: {Users: existingUsers * 2},
}),
},
};
});
jest.mock('components/payment_form/stripe', () => {
const original = jest.requireActual('components/payment_form/stripe');
return {
__esModule: true,
...original,
getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}),
};
});
jest.mock('utils/hosted_customer', () => {
const original = jest.requireActual('utils/hosted_customer');
return {
__esModule: true,
...original,
findSelfHostedProductBySku: () => {
return mockProfessionalProduct;
},
};
});
const productName = SelfHostedProducts.PROFESSIONAL;
// Licensed expiry set as 3 months from the current date (rolls over to new years).
let licenseExpiry = moment();
const monthsUntilLicenseExpiry = 3;
licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months');
const initialState: DeepPartial<GlobalState> = {
views: {
modals: {
modalState: {
[ModalIdentifiers.SELF_HOSTED_EXPANSION]: {
open: true,
},
},
},
},
storage: {
storage: {},
},
entities: {
teams: {
currentTeamId: '',
},
preferences: {
myPreferences: {
theme: {},
},
},
general: {
config: {
EnableDeveloper: 'false',
},
license: {
SkuName: productName,
Sku: productName,
Users: '50',
ExpiresAt: licenseExpiry.valueOf().toString(),
},
},
cloud: {
subscription: {},
},
users: {
currentUserId: 'adminUserId',
profiles: {
adminUserId: TH.getUserMock({
id: 'adminUserId',
roles: 'admin',
first_name: 'first',
last_name: 'admin',
}),
otherUserId: TH.getUserMock({
id: 'otherUserId',
roles: '',
first_name: '',
last_name: '',
}),
},
filteredStats: {
total_users_count: 100,
},
},
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: mockProfessionalProduct,
},
},
signupProgress: SelfHostedSignupProgress.START,
},
},
};
const valueEvent = (value: any) => ({target: {value}});
function changeByPlaceholder(sel: string, val: any) {
fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val));
}
function selectDropdownValue(testId: string, value: string) {
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any);
}
function changeByTestId(testId: string, value: string) {
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
}
interface PurchaseForm {
card: string;
org: string;
name: string;
country: string;
address: string;
city: string;
state: string;
zip: string;
seats: string;
agree: boolean;
}
const defaultSuccessForm: PurchaseForm = {
card: successCardNumber,
org: 'My org',
name: 'The Cardholder',
country: 'United States of America',
address: '123 Main Street',
city: 'Minneapolis',
state: 'MN',
zip: '55423',
seats: '50',
agree: true,
};
function fillForm(form: PurchaseForm) {
changeByPlaceholder('Card number', form.card);
changeByPlaceholder('Organization Name', form.org);
changeByPlaceholder('Name on Card', form.name);
selectDropdownValue('selfHostedExpansionCountrySelector', form.country);
changeByPlaceholder('Address', form.address);
changeByPlaceholder('City', form.city);
selectDropdownValue('selfHostedExpansionStateSelector', form.state);
changeByPlaceholder('Zip/Postal Code', form.zip);
if (form.agree) {
fireEvent.click(screen.getByText('I have read and agree', {exact: false}));
}
const completeButton = screen.getByText('Complete purchase');
if (form === defaultSuccessForm) {
expect(completeButton).toBeEnabled();
}
return completeButton;
}
describe('SelfHostedExpansionModal Open', () => {
it('renders the form', () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
screen.getByText('Provide your payment details');
screen.getByText('Add new seats');
screen.getByText('Contact Sales');
screen.getByText('Cost per user', {exact: false});
// screen.getByText(productName, {normalizer: (val) => {return val.charAt(0).toUpperCase() + val.slice(1)}});
screen.getByText('Your credit card will be charged today.');
screen.getByText('See how billing works', {exact: false});
});
it('filling the form enables expansion', () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
fillForm(defaultSuccessForm);
});
it('happy path submit shows success screen when confirmation succeeds', async () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
const upgradeButton = fillForm(defaultSuccessForm);
upgradeButton.click();
expect(screen.findByText('The license has been automatically applied')).toBeTruthy();
});
it('happy path submit shows submitting screen while requesting confirmation', async () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
const upgradeButton = fillForm(defaultSuccessForm);
upgradeButton.click();
await waitFor(() => expect(document.getElementsByClassName('submitting')[0]).toBeTruthy(), {timeout: 1234});
});
it('sad path submit shows error screen', async () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
fillForm(defaultSuccessForm);
changeByPlaceholder('Organization Name', failOrg);
const upgradeButton = screen.getByText('Complete purchase');
expect(upgradeButton).toBeEnabled();
upgradeButton.click();
await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234});
});
});
describe('SelfHostedExpansionModal RHS Card', () => {
it('New seats input should be pre-populated with the difference from the active users and licensed seats', () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '1', 10);
const seatsField = screen.getByTestId('seatsInput').querySelector('input');
expect(seatsField).toBeInTheDocument();
expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString());
});
it('Seat input only allows users to fill input with the licensed seats and active users difference if it is not 0', () => {
const expectedUserOverage = '50';
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
fillForm(defaultSuccessForm);
// The seat input should already have the expected value.
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage);
// Try to set an undefined value.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined);
// Expecting the seats input to now contain the difference between active users and licensed seats.
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage);
expect(screen.getByText('Complete purchase')).toBeEnabled();
});
it('New seats input cannot be less than 1', () => {
const state = mergeObjects(initialState, {
entities: {
users: {
filteredStats: {
total_users_count: 50,
},
},
},
});
const expectedAddNewSeats = '1';
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, state);
fillForm(defaultSuccessForm);
// Try to set a negative value.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, -10);
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats);
// Try to set a 0 value.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, 0);
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats);
});
it('Cost per User should be represented as the current subscription price multiplied by the remaining months', () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
const expectedCostPerUser = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat;
const costPerUser = document.getElementsByClassName('costPerUser')[0];
expect(costPerUser).toBeInTheDocument();
expect(costPerUser.innerHTML).toContain('Cost per user<br>$' + mockProfessionalProduct.price_per_seat.toFixed(2) + ' x ' + monthsUntilLicenseExpiry + ' months');
const costAmount = document.getElementsByClassName('costAmount')[0];
expect(costAmount).toBeInTheDocument();
expect(costAmount.innerHTML).toContain('$' + expectedCostPerUser);
});
it('Total cost User should be represented as the current subscription price multiplied by the remaining months multiplied by the number of users', () => {
renderWithContext(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
const seatsInputValue = 100;
changeByTestId('seatsInput', seatsInputValue.toString());
const expectedTotalCost = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat * seatsInputValue;
const costAmount = document.getElementsByClassName('totalCostAmount')[0];
expect(costAmount).toBeInTheDocument();
expect(costAmount).toHaveTextContent(Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(expectedTotalCost));
});
});
describe('SelfHostedExpansionModal Submit', () => {
function makeHappyPathState(): FormState {
return {
address: 'string',
address2: 'string',
city: 'string',
state: 'string',
country: 'string',
postalCode: '12345',
shippingAddress: 'string',
shippingAddress2: 'string',
shippingCity: 'string',
shippingState: 'string',
shippingCountry: 'string',
shippingPostalCode: '12345',
shippingSame: false,
agreedTerms: true,
cardName: 'string',
organization: 'string',
cardFilled: true,
seats: 1,
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
it('if submitting, can not submit again', () => {
const state = makeHappyPathState();
state.submitting = true;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false);
});
it('if created license, can submit', () => {
const state = makeInitialState(1);
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true);
});
it('if paid, can submit', () => {
const state = makeInitialState(1);
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true);
});
it('if created subscription, can submit', () => {
const state = makeInitialState(1);
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true);
});
it('if all details filled and card has not been confirmed, can submit', () => {
const state = makeHappyPathState();
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true);
});
it('if card name missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardName = '';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if card number missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardFilled = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if address not filled and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.address = '';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if seats not valid and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.seats = 0;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if card confirmed, card not required for submission', () => {
const state = makeHappyPathState();
state.cardFilled = false;
state.cardName = '';
expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true);
});
it('if passed unknown progress status, can not submit', () => {
const state = makeHappyPathState();
expect(canSubmit(state, 'unknown status' as any)).toBe(false);
});
});

View File

@ -1,536 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {StripeCardElementChangeEvent} from '@stripe/stripe-js';
import classNames from 'classnames';
import React, {useEffect, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {SelfHostedSignupCustomerResponse} from '@mattermost/types/hosted_customer';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import type {ValueOf} from '@mattermost/types/utilities';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {getLicenseConfig} from 'mattermost-redux/actions/general';
import {Client4} from 'mattermost-redux/client';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users';
import {confirmSelfHostedExpansion} from 'actions/hosted_customer';
import {pageVisited} from 'actions/telemetry_actions';
import {closeModal} from 'actions/views/modals';
import {isCwsMockMode} from 'selectors/cloud';
import ChooseDifferentShipping from 'components/choose_different_shipping';
import useLoadStripe from 'components/common/hooks/useLoadStripe';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import CardInput from 'components/payment_form/card_input';
import type {CardInputType} from 'components/payment_form/card_input';
import RootPortal from 'components/root_portal';
import Address from 'components/self_hosted_purchases/address';
import ContactSalesLink from 'components/self_hosted_purchases/contact_sales_link';
import ErrorPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/error_page';
import SuccessPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/success_page';
import Terms from 'components/self_hosted_purchases/self_hosted_purchase_modal/terms';
import Input from 'components/widgets/inputs/input/input';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {inferNames} from 'utils/hosted_customer';
import SelfHostedExpansionCard from './expansion_card';
import Submitting from './submitting';
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from '../constants';
import StripeProvider from '../stripe_provider';
import './self_hosted_expansion_modal.scss';
export interface FormState {
cardName: string;
cardFilled: boolean;
address: string;
address2: string;
city: string;
state: string;
country: string;
postalCode: string;
organization: string;
seats: number;
shippingSame: boolean;
shippingAddress: string;
shippingAddress2: string;
shippingCity: string;
shippingState: string;
shippingCountry: string;
shippingPostalCode: string;
agreedTerms: boolean;
submitting: boolean;
succeeded: boolean;
progressBar: number;
error: string;
}
export function makeInitialState(seats: number): FormState {
return {
cardName: '',
cardFilled: false,
address: '',
address2: '',
city: '',
state: '',
country: '',
postalCode: '',
organization: '',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
seats,
agreedTerms: false,
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
export function canSubmit(formState: FormState, progress: ValueOf<typeof SelfHostedSignupProgress>) {
if (formState.submitting) {
return false;
}
const validAddress = Boolean(
formState.organization &&
formState.address &&
formState.city &&
formState.state &&
formState.postalCode &&
formState.country,
);
const validShippingAddress = Boolean(
formState.shippingSame ||
(formState.shippingAddress &&
formState.shippingCity &&
formState.shippingState &&
formState.shippingPostalCode &&
formState.shippingCountry),
);
const agreedToTerms = formState.agreedTerms;
const validCard = Boolean(
formState.cardName &&
formState.cardFilled,
);
const validSeats = formState.seats > 0;
switch (progress) {
case SelfHostedSignupProgress.PAID:
case SelfHostedSignupProgress.CREATED_LICENSE:
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return true;
case SelfHostedSignupProgress.CONFIRMED_INTENT: {
return Boolean(
validAddress && validShippingAddress && validSeats && agreedToTerms,
);
}
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return Boolean(
validCard &&
validAddress &&
validShippingAddress &&
validSeats &&
agreedToTerms,
);
default: {
return false;
}
}
}
export default function SelfHostedExpansionModal() {
const dispatch = useDispatch();
const intl = useIntl();
const cardRef = useRef<CardInputType | null>(null);
const theme = useSelector(getTheme);
const progress = useSelector(getSelfHostedSignupProgress);
const user = useSelector(getCurrentUser);
const cwsMockMode = useSelector(isCwsMockMode);
const license = useSelector(getLicense);
const licensedSeats = parseInt(license.Users, 10);
const currentPlan = license.SkuName;
const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0;
const [minimumSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats);
const [requestedSeats, setRequestedSeats] = useState(minimumSeats);
const [stripeLoadHint, setStripeLoadHint] = useState(Math.random());
const stripeRef = useLoadStripe(stripeLoadHint);
const initialState = makeInitialState(requestedSeats);
const [formState, setFormState] = useState<FormState>(initialState);
const [show] = useState(true);
const canRetry = formState.error !== '422';
const showForm = progress !== SelfHostedSignupProgress.PAID && progress !== SelfHostedSignupProgress.CREATED_LICENSE && !formState.submitting && !formState.error && !formState.succeeded;
const title = intl.formatMessage({
id: 'self_hosted_expansion.expansion_modal.title',
defaultMessage: 'Provide your payment details',
});
const canSubmitForm = canSubmit(formState, progress);
const submit = async () => {
let submitProgress = progress;
let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null;
setFormState({...formState, submitting: true});
try {
const [firstName, lastName] = inferNames(user, formState.cardName);
signupCustomerResult = await Client4.createCustomerSelfHostedSignup({
first_name: firstName,
last_name: lastName,
billing_address: {
city: formState.city,
country: formState.country,
line1: formState.address,
line2: formState.address2,
postal_code: formState.postalCode,
state: formState.state,
},
shipping_address: {
city: formState.city,
country: formState.country,
line1: formState.address,
line2: formState.address2,
postal_code: formState.postalCode,
state: formState.state,
},
organization: formState.organization,
});
} catch {
setFormState({...formState, error: 'Failed to submit payment information'});
return;
}
if (signupCustomerResult === null) {
setStripeLoadHint(Math.random());
setFormState({...formState, submitting: false});
return;
}
if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) {
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: signupCustomerResult.progress,
});
submitProgress = signupCustomerResult.progress;
}
if (stripeRef.current === null) {
setStripeLoadHint(Math.random());
setFormState({...formState, submitting: false});
return;
}
try {
const card = cardRef.current?.getCard();
if (!card) {
const message = 'Failed to get card when it was expected';
setFormState({...formState, error: message});
return;
}
const finished = await dispatch(confirmSelfHostedExpansion(
stripeRef.current,
{
id: signupCustomerResult.setup_intent_id,
client_secret: signupCustomerResult.setup_intent_secret,
},
cwsMockMode,
{
address: formState.address,
address2: formState.address2,
city: formState.city,
state: formState.state,
country: formState.country,
postalCode: formState.postalCode,
name: formState.cardName,
card,
},
submitProgress,
{
seats: formState.seats,
license_id: license.Id,
},
));
if (finished.data) {
setFormState({...formState, succeeded: true});
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: SelfHostedSignupProgress.CREATED_LICENSE,
});
// Reload license in background.
// Needed if this was completed while on the Edition and License page.
dispatch(getLicenseConfig());
} else if (finished.error) {
let errorData = finished.error;
if (finished.error === 422) {
errorData = finished.error.toString();
}
setFormState({...formState, error: errorData});
return;
}
setFormState({...formState, submitting: false});
} catch (e) {
setFormState({...formState, error: 'unable to complete signup'});
}
};
useEffect(() => {
pageVisited(
TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION,
'pageview_self_hosted_expansion',
);
localStorage.setItem(STORAGE_KEY_EXPANSION_IN_PROGRESS, 'true');
return () => {
localStorage.removeItem(STORAGE_KEY_EXPANSION_IN_PROGRESS);
};
}, []);
const resetToken = () => {
try {
Client4.bootstrapSelfHostedSignup(true).
then((data) => {
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: data.progress,
});
});
} catch {
// swallow error ok here
}
};
return (
<StripeProvider
stripeRef={stripeRef}
>
<RootPortal>
<FullScreenModal
show={show}
ariaLabelledBy='self_hosted_expansion_modal_title'
onClose={() => {
dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION));
resetToken();
}}
>
<div className='SelfHostedExpansionModal'>
<div className={classNames('form-view', {'form-view--hide': !showForm})}>
<div className='lhs'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
width={267}
height={227}
/>
<div className='footer-text'>{'Questions?'}</div>
<ContactSalesLink/>
</div>
<div className='center'>
<div
className='form'
data-testid='expansion-modal'
>
<span className='section-title'>
{intl.formatMessage({
id: 'payment_form.credit_card',
defaultMessage: 'Credit Card',
})}
</span>
<div className='form-row'>
<CardInput
forwardedRef={cardRef}
required={true}
onCardInputChange={(event: StripeCardElementChangeEvent) => {
setFormState({...formState, cardFilled: event.complete});
}}
theme={theme}
/>
</div>
<div className='form-row'>
<Input
name='organization'
type='text'
value={formState.organization}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, organization: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'self_hosted_signup.organization',
defaultMessage: 'Organization Name',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='name'
type='text'
value={formState.cardName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, cardName: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.name_on_card',
defaultMessage: 'Name on Card',
})}
required={true}
/>
</div>
<span className='section-title'>
<FormattedMessage
id='payment_form.billing_address'
defaultMessage='Billing address'
/>
</span>
<Address
testPrefix='selfHostedExpansion'
type='billing'
country={formState.country}
changeCountry={(option) => {
setFormState({...formState, country: option.value});
}}
address={formState.address}
changeAddress={(e) => {
setFormState({...formState, address: e.target.value});
}}
address2={formState.address2}
changeAddress2={(e) => {
setFormState({...formState, address2: e.target.value});
}}
city={formState.city}
changeCity={(e) => {
setFormState({...formState, city: e.target.value});
}}
state={formState.state}
changeState={(state: string) => {
setFormState({...formState, state});
}}
postalCode={formState.postalCode}
changePostalCode={(e) => {
setFormState({...formState, postalCode: e.target.value});
}}
/>
<ChooseDifferentShipping
shippingIsSame={formState.shippingSame}
setShippingIsSame={(val: boolean) => {
setFormState({...formState, shippingSame: val});
}}
/>
{!formState.shippingSame && (
<>
<div className='section-title'>
<FormattedMessage
id='payment_form.shipping_address'
defaultMessage='Shipping Address'
/>
</div>
<Address
testPrefix='shippingSelfHostedExpansion'
type='shipping'
country={formState.shippingCountry}
changeCountry={(option) => {
setFormState({...formState, shippingCountry: option.value});
}}
address={formState.shippingAddress}
changeAddress={(e) => {
setFormState({...formState, shippingAddress: e.target.value});
}}
address2={formState.shippingAddress2}
changeAddress2={(e) => {
setFormState({...formState, shippingAddress2: e.target.value});
}}
city={formState.shippingCity}
changeCity={(e) => {
setFormState({...formState, shippingCity: e.target.value});
}}
state={formState.shippingState}
changeState={(state: string) => {
setFormState({...formState, shippingState: state});
}}
postalCode={formState.shippingPostalCode}
changePostalCode={(e) => {
setFormState({...formState, shippingPostalCode: e.target.value});
}}
/>
</>
)}
<Terms
agreed={formState.agreedTerms}
setAgreed={(data: boolean) => {
setFormState({...formState, agreedTerms: data});
}}
/>
</div>
</div>
<div className='rhs'>
<SelfHostedExpansionCard
updateSeats={(seats: number) => {
setFormState({...formState, seats});
setRequestedSeats(seats);
}}
canSubmit={canSubmitForm}
submit={submit}
licensedSeats={licensedSeats}
minimumSeats={minimumSeats}
/>
</div>
</div>
{((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && (
<SuccessPage
onClose={() => {
setFormState({...formState, submitting: false, error: '', succeeded: false});
dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION));
}}
/>
)}
{formState.submitting && (
<Submitting
currentPlan={currentPlan}
/>
)}
{formState.error && (
<ErrorPage
canRetry={canRetry}
tryAgain={() => {
setFormState({...formState, submitting: false, error: ''});
}}
/>
)}
<div className='background-svg'>
<BackgroundSvg/>
</div>
</div>
</FullScreenModal>
</RootPortal>
</StripeProvider>
);
}

View File

@ -1,183 +0,0 @@
.SelfHostedExpansionModal {
height: 100%;
.form-view {
display: flex;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: top;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
overflow-x: hidden;
.title {
font-size: 22px;
font-weight: 600;
}
.form {
padding: 0 96px;
margin: 0 auto;
.form-row {
display: flex;
width: 100%;
margin-bottom: 24px;
}
.form-row-third-1 {
width: 66%;
max-width: 288px;
margin-right: 16px;
.DropdownInput {
margin-top: 0;
}
}
.DropdownInput {
position: relative;
height: 36px;
margin-bottom: 24px;
.Input_fieldset {
height: 43px;
}
}
.form-row-third-2 {
width: 34%;
max-width: 144px;
}
.section-title {
display: block;
margin-bottom: 10px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 16px;
font-weight: 600;
text-align: left;
}
.Input_fieldset {
height: 40px;
padding: 2px 1px;
background: var(--center-channel-bg);
.Input {
background: inherit;
}
.Input_wrapper {
margin: 0;
}
}
}
&--hide {
display: none;
}
>.lhs {
width: 25%;
}
>.center {
width: 50%;
}
>.rhs {
position: sticky;
display: flex;
width: 25%;
flex-direction: column;
align-items: center;
}
.submitting,
.success,
.failed {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
.IconMessage .content .IconMessage-link {
margin-left: 0;
}
}
.background-svg {
position: absolute;
z-index: -1;
top: 0;
width: 100%;
height: 100%;
>div {
position: absolute;
top: 0;
left: 0;
}
}
.self-hosted-agreed-terms {
label {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
input[type=checkbox] {
width: 17px;
height: 17px;
flex-shrink: 0;
margin-right: 12px;
}
font-size: 16px;
}
}
@media (max-width: 1020px) {
.SelfHostedExpansionModal {
.form-view {
>.lhs {
display: none;
}
>.center {
width: 66%;
}
>.rhs {
width: 33%;
}
}
}
}
.FullScreenModal {
.close-x {
top: 12px;
right: 12px;
}
}
}

View File

@ -1,123 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import type {ValueOf} from '@mattermost/types/utilities';
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import './submitting_page.scss';
function useConvertProgressToWaitingExplanation(progress: ValueOf<typeof SelfHostedSignupProgress>, planName: string): React.ReactNode {
const intl = useIntl();
switch (progress) {
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.submitting_payment',
defaultMessage: 'Submitting payment information',
});
case SelfHostedSignupProgress.CONFIRMED_INTENT:
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.verifying_payment',
defaultMessage: 'Verifying payment details',
});
case SelfHostedSignupProgress.PAID:
case SelfHostedSignupProgress.CREATED_LICENSE:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.applying_license',
defaultMessage: 'Applying your {planName} license to your Mattermost instance',
}, {planName});
default:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.submitting_payment',
defaultMessage: 'Submitting payment information',
});
}
}
export function convertProgressToBar(progress: ValueOf<typeof SelfHostedSignupProgress>): number {
switch (progress) {
case SelfHostedSignupProgress.START:
return 15;
case SelfHostedSignupProgress.CREATED_CUSTOMER:
return 30;
case SelfHostedSignupProgress.CREATED_INTENT:
return 45;
case SelfHostedSignupProgress.CONFIRMED_INTENT:
return 60;
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return 75;
case SelfHostedSignupProgress.PAID:
return 85;
case SelfHostedSignupProgress.CREATED_LICENSE:
return 100;
default:
return 0;
}
}
interface Props {
currentPlan: string;
}
const maxProgressBar = 100;
const maxFakeProgressIncrement = 5;
const fakeProgressInterval = 600;
export default function Submitting(props: Props) {
const [barProgress, setBarProgress] = useState(0);
const signupProgress = useSelector(getSelfHostedSignupProgress);
const waitingExplanation = useConvertProgressToWaitingExplanation(signupProgress, props.currentPlan);
const footer = (
<div className='ProcessPayment-progress'>
<div
className='ProcessPayment-progress-fill'
style={{width: `${barProgress}%`}}
/>
</div>
);
useEffect(() => {
const maxProgressForCurrentSignupProgress = convertProgressToBar(signupProgress);
const interval = setInterval(() => {
if (barProgress < maxProgressBar) {
setBarProgress(Math.min(maxProgressForCurrentSignupProgress, barProgress + maxFakeProgressIncrement));
}
}, fakeProgressInterval);
return () => clearInterval(interval);
}, [barProgress]);
return (
<div className='submitting'>
<IconMessage
formattedTitle={(
<FormattedMessage
id='admin.billing.subscription.verifyPaymentInformation'
defaultMessage='Verifying your payment information'
/>
)}
formattedSubtitle={waitingExplanation}
icon={
<CreditCardSvg
width={444}
height={313}
/>
}
footer={footer}
className={'processing'}
/>
</div>
);
}

View File

@ -1,7 +0,0 @@
.submitting {
overflow: hidden;
.processing {
margin-top: 163px;
}
}

View File

@ -1,23 +0,0 @@
.SelfHostedPurchaseModal__success {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-size: 16px;
font-weight: 600;
}
.self_hosted_expansion_success {
overflow: hidden;
.selfHostedExpansionModal__success {
margin-top: 163px;
}
}

View File

@ -1,79 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useHistory} from 'react-router-dom';
import {trackEvent} from 'actions/telemetry_actions';
import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import {ConsolePages, TELEMETRY_CATEGORIES} from 'utils/constants';
import './success_page.scss';
interface Props {
onClose: () => void;
}
export default function SelfHostedExpansionSuccessPage(props: Props) {
const history = useHistory();
const titleText = (
<FormattedMessage
id={'self_hosted_expansion.expand_success'}
defaultMessage={"You've successfully updated your license seat count"}
/>
);
const formattedSubtitleText = (
<FormattedMessage
id={'self_hosted_expansion.license_applied'}
defaultMessage={'The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the <billing>Billing section</billing> of the system console.'}
values={{
billing: (billingText: React.ReactNode) => (
<a
href='#'
onClick={() => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'success_screen_closed');
history.push(ConsolePages.BILLING_HISTORY);
props.onClose();
}}
>
{billingText}
</a>
),
}}
/>
);
const formattedButtonText = (
<FormattedMessage
id={'self_hosted_expansion.close'}
defaultMessage={'Close'}
/>
);
const icon = (
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
);
return (
<div className='self_hosted_expansion_success'>
<IconMessage
className={'selfHostedExpansionModal__success'}
formattedTitle={titleText}
formattedSubtitle={formattedSubtitleText}
testId='selfHostedExpansionSuccess'
icon={icon}
formattedButtonText={formattedButtonText}
buttonHandler={props.onClose}
/>
</div>
);
}

View File

@ -1,103 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import AccessDeniedHappySvg from 'components/common/svg_images_components/access_denied_happy_svg';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import ExternalLink from 'components/external_link';
import IconMessage from 'components/purchase_modal/icon_message';
interface Props {
nextAction: () => void;
canRetry: boolean;
errorType: 'failed_export' | 'generic';
}
export default function ErrorPage(props: Props) {
const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error');
let formattedTitle = (
<FormattedMessage
id='admin.billing.subscription.paymentVerificationFailed'
defaultMessage='Sorry, the payment verification failed'
/>
);
let formattedButtonText = (
<FormattedMessage
id='self_hosted_signup.retry'
defaultMessage='Try again'
/>
);
if (!props.canRetry) {
formattedButtonText = (
<FormattedMessage
id='self_hosted_signup.close'
defaultMessage='Close'
/>
);
}
let formattedSubtitle = (
<FormattedMessage
id='admin.billing.subscription.paymentFailed'
defaultMessage='Payment failed. Please try again or contact support.'
/>
);
let icon = (
<PaymentFailedSvg
width={444}
height={313}
/>
);
if (props.errorType === 'failed_export') {
formattedTitle = (
<FormattedMessage
id='self_hosted_signup.failed_export.title'
defaultMessage='Your transaction is being reviewed'
/>
);
formattedSubtitle = (
<FormattedMessage
id='self_hosted_signup.failed_export.subtitle'
defaultMessage='We will check things on our side and get back to you within 3 days once your license is approved. In the meantime, please feel free to continue using the free version of our product.'
/>
);
icon = (
<AccessDeniedHappySvg
width={444}
height={313}
/>
);
}
return (
<div className='failed'>
<IconMessage
formattedTitle={formattedTitle}
formattedSubtitle={formattedSubtitle}
icon={icon}
error={true}
formattedButtonText={formattedButtonText}
buttonHandler={props.nextAction}
formattedLinkText={
<ExternalLink
href={contactSupportLink}
location='self_hosted_purchase_modal_error'
>
<FormattedMessage
id='admin.billing.subscription.privateCloudCard.contactSupport'
defaultMessage='Contact Support'
/>
</ExternalLink>
}
/>
</div>
);
}

View File

@ -1,445 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import type {SelfHostedSignupForm} from '@mattermost/types/hosted_customer';
import type {DeepPartial} from '@mattermost/types/utilities';
import {
fireEvent,
renderWithContext,
screen,
waitFor,
} from 'tests/react_testing_utils';
import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants';
import {TestHelper as TH} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import SelfHostedPurchaseModal, {makeInitialState, canSubmit} from '.';
import type {State} from '.';
interface MockCardInputProps {
onCardInputChange: (event: {complete: boolean}) => void;
forwardedRef: React.MutableRefObject<any>;
}
// number borrowed from stripe
const successCardNumber = '4242424242424242';
function MockCardInput(props: MockCardInputProps) {
props.forwardedRef.current = {
getCard: () => ({}),
};
return (
<input
placeholder='Card number'
type='text'
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === successCardNumber) {
props.onCardInputChange({complete: true});
}
}}
/>
);
}
jest.mock('components/payment_form/card_input', () => {
const original = jest.requireActual('components/payment_form/card_input');
return {
...original,
__esModule: true,
default: MockCardInput,
};
});
jest.mock('components/self_hosted_purchases/stripe_provider', () => {
return function(props: {children: React.ReactNode | React.ReactNodeArray}) {
return props.children;
};
});
jest.mock('components/common/hooks/useLoadStripe', () => {
return function() {
return {current: {
stripe: {},
}};
};
});
const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT;
const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE;
const failOrg = 'failorg';
const existingUsers = 11;
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
return {
__esModule: true,
...original,
Client4: {
...original.Client4,
pageVisited: jest.fn(),
setAcceptLanguage: jest.fn(),
trackEvent: jest.fn(),
createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => {
if (form.organization === failOrg) {
throw new Error('error creating customer');
}
return Promise.resolve({
progress: mockCreatedIntent,
});
},
confirmSelfHostedSignup: () => Promise.resolve({
progress: mockCreatedLicense,
license: {Users: existingUsers * 2},
}),
getClientLicenseOld: () => Promise.resolve({
data: {Sku: 'Enterprise'},
}),
},
};
});
jest.mock('components/payment_form/stripe', () => {
const original = jest.requireActual('components/payment_form/stripe');
return {
__esModule: true,
...original,
getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}),
};
});
const productName = 'Professional';
const initialState: DeepPartial<GlobalState> = {
views: {
modals: {
modalState: {
[ModalIdentifiers.SELF_HOSTED_PURCHASE]: {
open: true,
},
},
},
},
storage: {
storage: {},
},
entities: {
admin: {
analytics: {
TOTAL_USERS: existingUsers,
},
},
teams: {
currentTeamId: '',
},
preferences: {
myPreferences: {
theme: {},
},
},
general: {
config: {
EnableDeveloper: 'false',
},
license: {
Sku: 'Enterprise',
},
},
cloud: {
subscription: {},
},
users: {
currentUserId: 'adminUserId',
profiles: {
adminUserId: TH.getUserMock({
id: 'adminUserId',
roles: 'admin',
first_name: 'first',
last_name: 'admin',
}),
otherUserId: TH.getUserMock({
id: 'otherUserId',
roles: '',
first_name: '',
last_name: '',
}),
},
},
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: TH.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
}),
},
},
signupProgress: SelfHostedSignupProgress.START,
},
},
};
const valueEvent = (value: any) => ({target: {value}});
function changeByPlaceholder(sel: string, val: any) {
fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val));
}
// having issues with normal selection of texts and clicks.
function selectDropdownValue(testId: string, value: string) {
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any);
}
interface PurchaseForm {
card: string;
org: string;
name: string;
country: string;
address: string;
city: string;
state: string;
zip: string;
agree: boolean;
}
const defaultSuccessForm: PurchaseForm = {
card: successCardNumber,
org: 'My org',
name: 'The Cardholder',
country: 'United States of America',
address: '123 Main Street',
city: 'Minneapolis',
state: 'MN',
zip: '55423',
agree: true,
};
function fillForm(form: PurchaseForm) {
changeByPlaceholder('Card number', form.card);
changeByPlaceholder('Organization Name', form.org);
changeByPlaceholder('Name on Card', form.name);
selectDropdownValue('selfHostedPurchaseCountrySelector', form.country);
changeByPlaceholder('Address', form.address);
changeByPlaceholder('City', form.city);
selectDropdownValue('selfHostedPurchaseStateSelector', form.state);
changeByPlaceholder('Zip/Postal Code', form.zip);
if (form.agree) {
fireEvent.click(screen.getByText('I have read and agree', {exact: false}));
}
// not changing the license seats number,
// because it is expected to be pre-filled with the correct number of seats.
const upgradeButton = screen.getByText('Upgrade');
// while this will will not if the caller passes in an object
// that has member equality but not reference equality, this is
// good enough for the limited usage this function has
if (form === defaultSuccessForm) {
expect(upgradeButton).toBeEnabled();
}
return upgradeButton;
}
describe('SelfHostedPurchaseModal', () => {
it('renders the form', () => {
renderWithContext(<div id='root-portal'><SelfHostedPurchaseModal productId={'prod_professional'}/></div>, initialState);
// check title, and some of the most prominent details and secondary actions
screen.getByText('Provide your payment details');
screen.getByText('Contact Sales');
screen.getByText('USD per seat/month', {exact: false});
screen.getByText('billed annually', {exact: false});
screen.getByText(productName);
screen.getByText('You will be billed today. Your license will be applied automatically', {exact: false});
screen.getByText('See how billing works', {exact: false});
});
it('filling the form enables signup', () => {
renderWithContext(<div id='root-portal'><SelfHostedPurchaseModal productId={'prod_professional'}/></div>, initialState);
expect(screen.getByText('Upgrade')).toBeDisabled();
fillForm(defaultSuccessForm);
});
it('disables signup if too few seats chosen', () => {
renderWithContext(<div id='root-portal'><SelfHostedPurchaseModal productId={'prod_professional'}/></div>, initialState);
fillForm(defaultSuccessForm);
const tooFewSeats = existingUsers - 1;
fireEvent.change(screen.getByTestId('selfHostedPurchaseSeatsInput'), valueEvent(tooFewSeats.toString()));
expect(screen.getByText('Upgrade')).toBeDisabled();
screen.getByText('Your workspace currently has 11 users', {exact: false});
});
it('Minimum of 10 seats is required for sign up', () => {
renderWithContext(<div id='root-portal'><SelfHostedPurchaseModal productId={'prod_professional'}/></div>, initialState);
fillForm(defaultSuccessForm);
const tooFewSeats = 9;
fireEvent.change(screen.getByTestId('selfHostedPurchaseSeatsInput'), valueEvent(tooFewSeats.toString()));
expect(screen.getByText('Upgrade')).toBeDisabled();
screen.getByText('Minimum of 10 seats required', {exact: false});
});
it('happy path submit shows success screen', async () => {
renderWithContext(<div id='root-portal'><SelfHostedPurchaseModal productId={'prod_professional'}/></div>, initialState);
expect(screen.getByText('Upgrade')).toBeDisabled();
const upgradeButton = fillForm(defaultSuccessForm);
upgradeButton.click();
await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234});
});
it('sad path submit shows error screen', async () => {
renderWithContext(<div id='root-portal'><SelfHostedPurchaseModal productId={'prod_professional'}/></div>, initialState);
expect(screen.getByText('Upgrade')).toBeDisabled();
fillForm(defaultSuccessForm);
changeByPlaceholder('Organization Name', failOrg);
const upgradeButton = screen.getByText('Upgrade');
expect(upgradeButton).toBeEnabled();
upgradeButton.click();
await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234});
});
});
describe('SelfHostedPurchaseModal :: canSubmit', () => {
function makeHappyPathState(): State {
return {
address: 'string',
address2: 'string',
city: 'string',
state: 'string',
country: 'string',
postalCode: '12345',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
cardName: 'string',
organization: 'string',
agreedTerms: true,
cardFilled: true,
seats: {
quantity: '12',
error: null,
},
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
it('if submitting, can not submit', () => {
const state = makeHappyPathState();
state.submitting = true;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false);
});
it('if created license, can submit', () => {
const state = makeInitialState();
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true);
});
it('if paid, can submit', () => {
const state = makeInitialState();
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true);
});
it('if created subscription, can submit', () => {
const state = makeInitialState();
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true);
});
it('if all details filled and card has not been confirmed, can submit', () => {
const state = makeHappyPathState();
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true);
});
it('if card name missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardName = '';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if shipping address different and is not filled, can not submit', () => {
const state = makeHappyPathState();
state.shippingSame = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
state.shippingAddress = 'more shipping info';
state.shippingAddress2 = 'more shipping info';
state.shippingCity = 'more shipping info';
state.shippingState = 'more shipping info';
state.shippingCountry = 'more shipping info';
state.shippingPostalCode = 'more shipping info';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
});
it('if card number missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardFilled = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if address not filled and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.address = '';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if seats not valid and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.seats.error = 'some seats error';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if did not agree to terms and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.agreedTerms = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if card confirmed, card not required for submission', () => {
const state = makeHappyPathState();
state.cardFilled = false;
state.cardName = '';
expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true);
});
it('if passed unknown progress status, can not submit', () => {
const state = makeHappyPathState();
expect(canSubmit(state, 'unknown status' as any)).toBe(false);
});
});

View File

@ -1,742 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {StripeCardElementChangeEvent} from '@stripe/stripe-js';
import classNames from 'classnames';
import React, {useEffect, useRef, useReducer, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import type {
SelfHostedSignupCustomerResponse} from '@mattermost/types/hosted_customer';
import {
SelfHostedSignupProgress,
} from '@mattermost/types/hosted_customer';
import type {ValueOf} from '@mattermost/types/utilities';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {getLicenseConfig} from 'mattermost-redux/actions/general';
import {Client4} from 'mattermost-redux/client';
import {getAdminAnalytics} from 'mattermost-redux/selectors/entities/admin';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getSelfHostedProducts, getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {confirmSelfHostedSignup} from 'actions/hosted_customer';
import {trackEvent, pageVisited} from 'actions/telemetry_actions';
import {isCwsMockMode} from 'selectors/cloud';
import {isModalOpen} from 'selectors/views/modals';
import ChooseDifferentShipping from 'components/choose_different_shipping';
import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal';
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
import useLoadStripe from 'components/common/hooks/useLoadStripe';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import CardInput from 'components/payment_form/card_input';
import type {CardInputType} from 'components/payment_form/card_input';
import RootPortal from 'components/root_portal';
import {errorInvalidNumber} from 'components/seats_calculator';
import type {Seats} from 'components/seats_calculator';
import Input from 'components/widgets/inputs/input/input';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import {
ModalIdentifiers,
StatTypes,
TELEMETRY_CATEGORIES,
} from 'utils/constants';
import {inferNames} from 'utils/hosted_customer';
import type {GlobalState} from 'types/store';
import ErrorPage from './error';
import SelfHostedCard from './self_hosted_card';
import Submitting, {convertProgressToBar} from './submitting';
import SuccessPage from './success_page';
import Terms from './terms';
import {SetPrefix} from './types';
import type {UnionSetActions} from './types';
import useNoEscape from './useNoEscape';
import Address from '../address';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from '../constants';
import ContactSalesLink from '../contact_sales_link';
import StripeProvider from '../stripe_provider';
import './self_hosted_purchase_modal.scss';
export interface State {
// billing address
address: string;
address2: string;
city: string;
state: string;
country: string;
postalCode: string;
// shipping address
shippingSame: boolean;
shippingAddress: string;
shippingAddress2: string;
shippingCity: string;
shippingState: string;
shippingCountry: string;
shippingPostalCode: string;
cardName: string;
organization: string;
agreedTerms: boolean;
cardFilled: boolean;
seats: Seats;
submitting: boolean;
succeeded: boolean;
progressBar: number;
error: string;
}
interface UpdateSucceeded {
type: 'succeeded';
}
interface UpdateProgressBarFake {
type: 'update_progress_bar_fake';
}
interface ClearError {
type: 'clear_error';
}
type SetActions = UnionSetActions<State>;
type Action = SetActions | UpdateProgressBarFake | UpdateSucceeded | ClearError
export function makeInitialState(): State {
return {
address: '',
address2: '',
city: '',
state: '',
country: '',
postalCode: '',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
cardName: '',
organization: '',
agreedTerms: false,
cardFilled: false,
seats: {
quantity: '0',
error: errorInvalidNumber,
},
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
const initialState = makeInitialState();
const maxFakeProgress = 90;
const maxFakeProgressIncrement = 5;
const fakeProgressInterval = 1500;
function getPlanNameFromProductName(productName: string): string {
if (productName.length > 0) {
const [name] = productName.split(' ').slice(-1);
return name;
}
return productName;
}
function isSetAction(action: Action): action is SetActions {
return Object.prototype.hasOwnProperty.call(action, 'data');
}
type SetKey=`${typeof SetPrefix}${Extract<keyof State, string>}`;
function actionTypeToStateKey(actionType: SetKey): Extract<keyof State, string> {
return actionType.slice(SetPrefix.length) as Extract<keyof State, string>;
}
function simpleSet(keys: Array<Extract<keyof State, string>>, state: State, action: Action): [State, boolean] {
if (!isSetAction(action)) {
return [state, false];
}
const stateKey = actionTypeToStateKey(action.type);
if (!keys.includes(stateKey)) {
return [state, false];
}
return [{...state, [stateKey]: action.data}, true];
}
// properties we can set the field on directly without needing to consider or modify other properties
const simpleSetters: Array<Extract<keyof State, string>> = [
'address',
'address2',
'city',
'country',
'state',
'postalCode',
// shipping address
'shippingSame',
'shippingAddress',
'shippingAddress2',
'shippingCity',
'shippingState',
'shippingCountry',
'shippingPostalCode',
'agreedTerms',
'cardFilled',
'cardName',
'organization',
'progressBar',
'seats',
'submitting',
];
function reducer(state: State, action: Action): State {
const [newState, handled] = simpleSet(simpleSetters, state, action);
if (handled) {
return newState;
}
switch (action.type) {
case 'update_progress_bar_fake': {
const firstLongStep = SelfHostedSignupProgress.CONFIRMED_INTENT;
if (state.progressBar >= convertProgressToBar(firstLongStep) && state.progressBar <= maxFakeProgress - maxFakeProgressIncrement) {
return {...state, progressBar: state.progressBar + maxFakeProgressIncrement};
}
return state;
}
case 'clear_error': {
return {
...state,
error: '',
};
}
case 'set_error': {
return {
...state,
submitting: false,
error: action.data,
};
}
case 'succeeded':
return {...state, submitting: false, succeeded: true};
default:
// eslint-disable-next-line no-console
console.error(`Exhaustiveness failure for self hosted purchase modal. action: ${JSON.stringify(action)}`);
return state;
}
}
export function canSubmit(state: State, progress: ValueOf<typeof SelfHostedSignupProgress>) {
if (state.submitting) {
return false;
}
let validAddress = Boolean(
state.organization &&
state.address &&
state.city &&
state.state &&
state.postalCode &&
state.country,
);
if (!state.shippingSame) {
validAddress = validAddress && Boolean(
state.shippingAddress &&
state.shippingCity &&
state.shippingState &&
state.shippingPostalCode &&
state.shippingCountry,
);
}
const validCard = Boolean(
state.cardName &&
state.cardFilled,
);
const validSeats = !state.seats.error;
switch (progress) {
case SelfHostedSignupProgress.PAID:
case SelfHostedSignupProgress.CREATED_LICENSE:
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return true;
case SelfHostedSignupProgress.CONFIRMED_INTENT: {
return Boolean(
validAddress &&
validSeats &&
state.agreedTerms,
);
}
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return Boolean(
validCard &&
validAddress &&
validSeats &&
state.agreedTerms,
);
default: {
// eslint-disable-next-line no-console
console.log(`Unexpected progress state: ${progress}`);
return false;
}
}
}
interface Props {
productId: string;
}
interface FakeProgress {
intervalId?: NodeJS.Timeout;
}
export default function SelfHostedPurchaseModal(props: Props) {
useFetchStandardAnalytics();
useNoEscape();
const controlModal = useControlSelfHostedPurchaseModal({productId: props.productId});
const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.SELF_HOSTED_PURCHASE));
const progress = useSelector(getSelfHostedSignupProgress);
const user = useSelector(getCurrentUser);
const theme = useSelector(getTheme);
const analytics = useSelector(getAdminAnalytics) || {};
const desiredProduct = useSelector(getSelfHostedProducts)[props.productId];
const desiredProductName = desiredProduct?.name || '';
const desiredPlanName = getPlanNameFromProductName(desiredProductName);
const currentUsers = analytics[StatTypes.TOTAL_USERS] as number;
const cwsMockMode = useSelector(isCwsMockMode);
const hasLicense = Object.keys(useSelector(getLicense) || {}).length > 0;
const intl = useIntl();
const fakeProgressRef = useRef<FakeProgress>({
});
const [state, dispatch] = useReducer(reducer, initialState);
const reduxDispatch = useDispatch();
const cardRef = useRef<CardInputType | null>(null);
const modalRef = useRef();
const [stripeLoadHint, setStripeLoadHint] = useState(Math.random());
const stripeRef = useLoadStripe(stripeLoadHint);
const showForm = progress !== SelfHostedSignupProgress.PAID && progress !== SelfHostedSignupProgress.CREATED_LICENSE && !state.submitting && !state.error && !state.succeeded;
useEffect(() => {
if (typeof currentUsers === 'number' && (currentUsers > parseInt(state.seats.quantity, 10) || !parseInt(state.seats.quantity, 10))) {
dispatch({type: 'set_seats',
data: {
quantity: currentUsers.toString(),
error: null,
}});
}
}, [currentUsers]);
useEffect(() => {
pageVisited(
TELEMETRY_CATEGORIES.CLOUD_PURCHASING,
'pageview_self_hosted_purchase',
);
localStorage.setItem(STORAGE_KEY_PURCHASE_IN_PROGRESS, 'true');
return () => {
localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS);
};
}, []);
useEffect(() => {
const progressBar = convertProgressToBar(progress);
if (progressBar > state.progressBar) {
dispatch({type: 'set_progressBar', data: progressBar});
}
}, [progress]);
useEffect(() => {
if (fakeProgressRef.current && fakeProgressRef.current.intervalId) {
clearInterval(fakeProgressRef.current.intervalId);
}
if (state.submitting) {
fakeProgressRef.current.intervalId = setInterval(() => {
dispatch({type: 'update_progress_bar_fake'});
}, fakeProgressInterval);
}
return () => {
if (fakeProgressRef.current && fakeProgressRef.current.intervalId) {
clearInterval(fakeProgressRef.current.intervalId);
}
};
}, [state.submitting]);
const handleCardInputChange = (event: StripeCardElementChangeEvent) => {
dispatch({type: 'set_cardFilled', data: event.complete});
};
async function submit() {
let submitProgress = progress;
dispatch({type: 'set_submitting', data: true});
let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null;
try {
const [firstName, lastName] = inferNames(user, state.cardName);
const billingAddress = {
city: state.city,
country: state.country,
line1: state.address,
line2: state.address2,
postal_code: state.postalCode,
state: state.state,
};
signupCustomerResult = await Client4.createCustomerSelfHostedSignup({
first_name: firstName,
last_name: lastName,
billing_address: billingAddress,
shipping_address: state.shippingSame ? billingAddress : {
city: state.shippingCity,
country: state.shippingCountry,
line1: state.shippingAddress,
line2: state.shippingAddress2,
postal_code: state.shippingPostalCode,
state: state.shippingState,
},
organization: state.organization,
});
} catch {
dispatch({type: 'set_error', data: 'Failed to submit payment information'});
return;
}
if (signupCustomerResult === null || !signupCustomerResult.progress) {
dispatch({type: 'set_error', data: 'Failed to submit payment information'});
return;
}
if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) {
reduxDispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: signupCustomerResult.progress,
});
submitProgress = signupCustomerResult.progress;
}
if (stripeRef.current === null) {
setStripeLoadHint(Math.random());
dispatch({type: 'set_submitting', data: false});
return;
}
try {
const card = cardRef.current?.getCard();
if (!card) {
const message = 'Failed to get card when it was expected';
// eslint-disable-next-line no-console
console.error(message);
dispatch({type: 'set_error', data: message});
return;
}
const finished = await reduxDispatch(confirmSelfHostedSignup(
stripeRef.current,
{
id: signupCustomerResult.setup_intent_id,
client_secret: signupCustomerResult.setup_intent_secret,
},
cwsMockMode,
{
address: state.address,
address2: state.address2,
city: state.city,
state: state.state,
country: state.country,
postalCode: state.postalCode,
name: state.cardName,
card,
},
submitProgress,
{
product_id: props.productId,
add_ons: [],
seats: parseInt(state.seats.quantity, 10),
},
));
if (finished.data) {
trackEvent(
TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING,
'purchase_success',
{seats: parseInt(finished.data?.Users, 10) || 0, users: currentUsers},
);
dispatch({type: 'succeeded'});
reduxDispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: SelfHostedSignupProgress.CREATED_LICENSE,
});
// Reload license in background.
// Needed if this was completed while on the Edition and License page.
reduxDispatch(getLicenseConfig());
} else if (finished.error) {
let errorData = finished.error;
if (finished.error === 422) {
errorData = finished.error.toString();
}
dispatch({type: 'set_error', data: errorData});
return;
}
dispatch({type: 'set_submitting', data: false});
} catch (e) {
// eslint-disable-next-line no-console
console.error('could not complete setup', e);
dispatch({type: 'set_error', data: 'unable to complete signup'});
}
}
const canSubmitForm = canSubmit(state, progress);
const title = (
<FormattedMessage
defaultMessage={'Provide your payment details'}
id={'admin.billing.subscription.providePaymentDetails'}
/>
);
const canRetry = state.error !== '422';
const resetToken = () => {
try {
Client4.bootstrapSelfHostedSignup(true).
then((data) => {
reduxDispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: data.progress,
});
});
} catch {
// swallow error ok here
}
};
const errorAction = () => {
if (canRetry && (progress === SelfHostedSignupProgress.CREATED_SUBSCRIPTION || progress === SelfHostedSignupProgress.PAID || progress === SelfHostedSignupProgress.CREATED_LICENSE)) {
submit();
dispatch({type: 'clear_error'});
return;
}
resetToken();
if (canRetry) {
dispatch({type: 'set_error', data: ''});
} else {
controlModal.close();
}
};
return (
<StripeProvider
stripeRef={stripeRef}
>
<RootPortal>
<FullScreenModal
show={show}
ref={modalRef}
ariaLabelledBy='self_hosted_purchase_modal_title'
onClose={() => {
trackEvent(
TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING,
'click_close_purchasing_screen',
);
resetToken();
controlModal.close();
}}
>
<div className='SelfHostedPurchaseModal'>
{<div className={classNames('form-view', {'form-view--hide': !showForm})}>
<div className='lhs'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
width={267}
height={227}
/>
<div className='footer-text'>{'Questions?'}</div>
<ContactSalesLink/>
</div>
<div className='center'>
<div
className='form'
data-testid='shpm-form'
>
<div className='section-title'>
<FormattedMessage
id='payment_form.credit_card'
defaultMessage='Credit Card'
/>
</div>
<div className='form-row'>
<CardInput
forwardedRef={cardRef}
required={true}
onCardInputChange={handleCardInputChange}
theme={theme}
/>
</div>
<div className='form-row'>
<Input
name='organization'
type='text'
value={state.organization}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_organization', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'self_hosted_signup.organization',
defaultMessage: 'Organization Name',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='name'
type='text'
value={state.cardName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_cardName', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.name_on_card',
defaultMessage: 'Name on Card',
})}
required={true}
/>
</div>
<div className='section-title'>
<FormattedMessage
id='payment_form.billing_address'
defaultMessage='Billing address'
/>
</div>
<Address
type='billing'
country={state.country}
changeCountry={(option) => {
dispatch({type: 'set_country', data: option.value});
}}
address={state.address}
changeAddress={(e) => {
dispatch({type: 'set_address', data: e.target.value});
}}
address2={state.address2}
changeAddress2={(e) => {
dispatch({type: 'set_address2', data: e.target.value});
}}
city={state.city}
changeCity={(e) => {
dispatch({type: 'set_city', data: e.target.value});
}}
state={state.state}
changeState={(state: string) => {
dispatch({type: 'set_state', data: state});
}}
postalCode={state.postalCode}
changePostalCode={(e) => {
dispatch({type: 'set_postalCode', data: e.target.value});
}}
/>
<ChooseDifferentShipping
shippingIsSame={state.shippingSame}
setShippingIsSame={(val: boolean) => {
dispatch({type: 'set_shippingSame', data: val});
}}
/>
{!state.shippingSame && (
<>
<div className='section-title'>
<FormattedMessage
id='payment_form.shipping_address'
defaultMessage='Shipping Address'
/>
</div>
<Address
type='shipping'
country={state.shippingCountry}
changeCountry={(option) => {
dispatch({type: 'set_shippingCountry', data: option.value});
}}
address={state.shippingAddress}
changeAddress={(e) => {
dispatch({type: 'set_shippingAddress', data: e.target.value});
}}
address2={state.shippingAddress2}
changeAddress2={(e) => {
dispatch({type: 'set_shippingAddress2', data: e.target.value});
}}
city={state.shippingCity}
changeCity={(e) => {
dispatch({type: 'set_shippingCity', data: e.target.value});
}}
state={state.shippingState}
changeState={(state: string) => {
dispatch({type: 'set_shippingState', data: state});
}}
postalCode={state.shippingPostalCode}
changePostalCode={(e) => {
dispatch({type: 'set_shippingPostalCode', data: e.target.value});
}}
/>
</>
)}
<Terms
agreed={state.agreedTerms}
setAgreed={(data: boolean) => {
dispatch({type: 'set_agreedTerms', data});
}}
/>
</div>
</div>
<div className='rhs'>
<SelfHostedCard
desiredPlanName={desiredPlanName}
desiredProduct={desiredProduct}
seats={state.seats}
currentUsers={currentUsers}
updateSeats={(seats: Seats) => {
dispatch({type: 'set_seats', data: seats});
}}
canSubmit={canSubmitForm}
submit={submit}
/>
</div>
</div>}
{((state.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !state.error && !state.submitting && (
<SuccessPage
onClose={controlModal.close}
planName={desiredPlanName}
/>
)}
{state.submitting && (
<Submitting
desiredPlanName={desiredPlanName}
progressBar={state.progressBar}
/>
)}
{state.error && (
<ErrorPage
nextAction={errorAction}
canRetry={canRetry}
errorType={canRetry ? 'generic' : 'failed_export'}
/>
)}
<div className='background-svg'>
<BackgroundSvg/>
</div>
</div>
</FullScreenModal>
</RootPortal>
</StripeProvider>
);
}

View File

@ -1,130 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Product} from '@mattermost/types/cloud';
import {trackEvent} from 'actions/telemetry_actions';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import PlanLabel from 'components/common/plan_label';
import {Card, ButtonCustomiserClasses} from 'components/purchase_modal/purchase_modal';
import SeatsCalculator from 'components/seats_calculator';
import type {Seats} from 'components/seats_calculator';
import Consequences from 'components/seats_calculator/consequences';
import StarMarkSvg from 'components/widgets/icons/star_mark_icon';
import {
SelfHostedProducts,
} from 'utils/constants';
// Card has a bunch of props needed for monthly/yearly payments that
// do not apply to self-hosted.
const dummyCardProps = {
isCloud: false,
usersCount: 0,
yearlyPrice: 0,
monthlyPrice: 0,
isInitialPlanMonthly: false,
updateIsMonthly: () => {},
updateInputUserCount: () => {},
setUserCountError: () => {},
isCurrentPlanMonthlyProfessional: false,
};
interface Props {
desiredPlanName: string;
desiredProduct: Product;
seats: Seats;
currentUsers: number;
updateSeats: (seats: Seats) => void;
canSubmit: boolean;
submit: () => void;
}
export default function SelfHostedCard(props: Props) {
const intl = useIntl();
const openPricingModal = useOpenPricingModal();
const showPlanLabel = props.desiredProduct.sku === SelfHostedProducts.PROFESSIONAL;
const comparePlan = (
<button
className='ml-1'
onClick={() => {
trackEvent('self_hosted_pricing', 'click_compare_plans');
openPricingModal({trackingLocation: 'purchase_modal_compare_plans_click'});
}}
>
<FormattedMessage
id='cloud_subscribe.contact_support'
defaultMessage='Compare plans'
/>
</button>
);
const comparePlanWrapper = (
<div
className={showPlanLabel ? 'plan_comparison show_label' : 'plan_comparison'}
>
{comparePlan}
</div>
);
return (
<>
{comparePlanWrapper}
<Card
{...dummyCardProps}
topColor='#4A69AC'
plan={props.desiredPlanName}
price={`${props.desiredProduct?.price_per_seat?.toString()}`}
rate={intl.formatMessage({id: 'pricing_modal.rate.seatPerMonth', defaultMessage: 'USD per seat/month {br}<b>(billed annually)</b>'}, {
br: <br/>,
b: (chunks: React.ReactNode | React.ReactNodeArray) => (
<span style={{fontSize: '14px'}}>
<b>{chunks}</b>
</span>
),
})}
planBriefing={<></>}
preButtonContent={(
<SeatsCalculator
price={props.desiredProduct?.price_per_seat}
seats={props.seats}
existingUsers={props.currentUsers}
isCloud={false}
onChange={props.updateSeats}
/>
)}
afterButtonContent={
<Consequences
isCloud={false}
licenseAgreementBtnText={intl.formatMessage({id: 'self_hosted_signup.cta', defaultMessage: 'Upgrade'})}
/>
}
buttonDetails={{
action: props.submit,
disabled: !props.canSubmit,
text: intl.formatMessage({id: 'self_hosted_signup.cta', defaultMessage: 'Upgrade'}),
customClass: props.canSubmit ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.grayed,
}}
planLabel={
showPlanLabel ? (
<PlanLabel
text={intl.formatMessage({
id: 'pricing_modal.planLabel.mostPopular',
defaultMessage: 'MOST POPULAR',
})}
bgColor='var(--title-color-indigo-500)'
color='var(--button-color)'
firstSvg={<StarMarkSvg/>}
secondSvg={<StarMarkSvg/>}
/>
) : undefined
}
/>
</>
);
}

View File

@ -1,552 +0,0 @@
.SelfHostedPurchaseModal {
overflow: hidden;
height: 100%;
.form-view {
display: flex;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
overflow-x: hidden;
overflow-y: auto;
.title {
font-size: 22px;
font-weight: 600;
}
.form {
padding: 0 96px;
margin: 0 auto;
.form-row {
display: flex;
width: 100%;
margin-bottom: 24px;
}
.form-row-third-1 {
width: 66%;
max-width: 288px;
margin-right: 16px;
.DropdownInput {
margin-top: 0;
}
}
.DropdownInput {
position: relative;
height: 36px;
margin-bottom: 24px;
.Input_fieldset {
height: 43px;
}
}
.form-row-third-2 {
width: 34%;
max-width: 144px;
}
.section-title {
margin-bottom: 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 16px;
font-weight: 600;
text-align: left;
}
.Input_fieldset {
height: 40px;
padding: 2px 1px;
background: var(--center-channel-bg);
.Input {
height: 32px;
background: inherit;
}
.Input_wrapper {
margin: 0;
}
}
}
&--hide {
display: none;
}
> .lhs {
width: 25%;
}
> .center {
width: 50%;
}
> .rhs {
position: sticky;
display: flex;
width: 25%;
flex-direction: column;
align-items: center;
.price-text {
display: flex;
align-items: baseline;
padding: 8px 0;
font-size: 32px;
font-weight: 600;
line-height: 1;
.price-decimals {
align-self: end;
padding-bottom: 2px;
font-size: 16px;
&::before {
content: '.';
}
}
}
.monthly-text {
align-self: center;
margin-left: 5px;
font-size: 14px;
font-weight: normal;
}
.plan_comparison {
&.show_label {
margin-bottom: 51px;
}
text-align: center;
button,
a {
width: 100%;
height: 40px;
padding: 10px 24px;
border: none;
background: none;
color: var(--denim-button-bg);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
}
.PlanCard {
position: relative;
width: 280px;
height: auto;
.planLabel {
position: absolute;
top: -44px;
left: 52px;
display: flex;
width: 184px;
align-items: center;
justify-content: center;
padding: 14px;
border-radius: 13px 13px 0 0;
font-family: 'Open Sans';
font-size: 12px;
font-style: normal;
font-weight: 600;
gap: 5px;
line-height: 16px;
}
.top {
height: 18px;
border-radius: 4px 4px 0 0;
}
.bottom {
&:not(.delinquency, .bottom-monthly-yearly) {
height: auto;
}
padding-right: 24px;
padding-bottom: 24px;
padding-left: 24px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 0 0 4px 4px;
background-color: var(--center-channel-bg);
.enable_annual_sub {
margin-top: 12px;
}
.plan_action_btn {
width: 100%;
height: 40px;
padding: 10px 24px;
border: none;
border-radius: 4px;
background: none;
color: var(--denim-button-bg);
font-size: 14px;
font-weight: 700;
line-height: 14px;
&.grayed {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.32);
}
&.active {
border: 1px solid var(--denim-button-bg);
}
&.special {
background: var(--denim-button-bg);
color: var(--button-color);
}
}
.button-description {
margin-top: 8px;
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
text-align: center;
}
.delinquency_summary_section {
width: 100%;
height: 116px;
margin-top: 24px;
margin-bottom: 24px;
font-weight: 400;
letter-spacing: -0.02em;
text-align: center;
.summary-section {
border-radius: 4px;
background-color: rgba(var(--denim-button-bg-rgb), 0.08);
font-family: 'Metropolis';
.summary-title {
padding-top: 12px;
font-size: 14px;
font-weight: 400;
}
.summary-total {
padding-top: 12px;
color: #152234;
font-size: 28px;
font-weight: 700;
line-height: 20px;
}
.view-breakdown {
&:hover {
cursor: pointer;
}
padding-top: 12px;
padding-bottom: 12px;
color: var(--denim-button-bg);
font-family: 'Metropolis';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
}
h1 {
color: var(--center-channel-color);
font-family: 'Metropolis';
font-size: 52px;
font-style: normal;
font-weight: 700;
line-height: 60px;
}
.enterprise_price {
font-size: 32px;
}
p {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: 'Metropolis';
font-size: 18px;
font-style: normal;
line-height: 20px;
}
}
.plan_price_rate_section {
width: 100%;
height: 150px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
font-weight: 400;
letter-spacing: -0.02em;
text-align: center;
h4 {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: 'Metropolis';
font-size: 20px;
font-style: normal;
line-height: 30px;
}
h4.plan_name {
margin-top: 16px;
margin-bottom: 0;
}
h1 {
margin: 0;
color: var(--center-channel-color);
font-family: 'Metropolis';
font-size: 52px;
font-style: normal;
font-weight: 700;
line-height: 60px;
}
.enterprise_price {
font-size: 32px;
}
p {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: 'Metropolis';
font-size: 18px;
font-style: normal;
line-height: 20px;
}
p.plan_text {
margin-bottom: 0;
}
}
.plan_payment_commencement {
margin-top: 16px;
margin-bottom: 24px;
color: var(--center-channel-color-rgb);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.plan_billing_cycle {
&.delinquency {
margin-bottom: 24px;
}
margin-top: 16px;
margin-bottom: 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
table {
width: 100%;
border-collapse: collapse;
.yearly_savings {
padding-top: 12px;
padding-bottom: 12px;
color: var(--denim-status-online);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 20px;
}
.total_price {
color: var(--sys-denim-center-channel-text);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 24px;
}
.monthly_price {
color: var(--sys-denim-center-channel-text);
font-family: 'Open Sans';
font-size: 14px;
font-style: normal;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 20px;
}
}
table tr td:nth-child(2) {
text-align: right;
}
.flex-grid {
display: flex;
margin-top: 20px;
.user_seats_container {
width: 100px;
.user_seats {
height: 24px;
}
.Input_container {
fieldset:not(.Input_fieldset___error) {
margin-bottom: 24px;
}
}
}
.icon {
flex: 1;
align-self: flex-start;
justify-content: left;
.icon-information-outline {
margin: 0;
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75);
}
}
.Input___customMessage {
width: 250px;
margin-bottom: 4px;
.icon.error {
display: none;
}
}
}
}
.bottom.bottom-monthly-yearly {
display: block;
overflow: auto;
border-radius: 4px;
}
}
}
}
.signup-consequences {
display: flex;
flex-direction: column;
margin-top: 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
span:first-of-type {
margin-bottom: 10px;
}
}
.submitting,
.success,
.failed {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
.IconMessage .content .IconMessage-link {
margin-left: 0;
}
}
.background-svg {
position: absolute;
z-index: -1;
top: 0;
width: 100%;
height: 100%;
>div {
position: absolute;
top: 0;
left: 0;
}
}
.self-hosted-agreed-terms {
label {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
input[type=checkbox] {
width: 17px;
height: 17px;
flex-shrink: 0;
margin-right: 12px;
}
font-size: 16px;
}
}
@media (max-width: 1020px) {
.SelfHostedPurchaseModal {
.form-view {
> .lhs {
display: none;
}
> .center {
width: 66%;
}
> .rhs {
width: 33%;
}
}
}
}
.FullScreenModal {
.close-x {
top: 12px;
right: 12px;
}
}

View File

@ -1,106 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import type {ValueOf} from '@mattermost/types/utilities';
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import IconMessage from 'components/purchase_modal/icon_message';
function useConvertProgressToWaitingExplanation(progress: ValueOf<typeof SelfHostedSignupProgress>, planName: string): React.ReactNode {
const intl = useIntl();
switch (progress) {
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.submitting_payment',
defaultMessage: 'Submitting payment information',
});
case SelfHostedSignupProgress.CONFIRMED_INTENT:
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.verifying_payment',
defaultMessage: 'Verifying payment details',
});
case SelfHostedSignupProgress.PAID:
case SelfHostedSignupProgress.CREATED_LICENSE:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.applying_license',
defaultMessage: 'Applying your {planName} license to your Mattermost instance',
}, {planName});
default:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.submitting_payment',
defaultMessage: 'Submitting payment information',
});
}
}
export function convertProgressToBar(progress: ValueOf<typeof SelfHostedSignupProgress>): number {
switch (progress) {
case SelfHostedSignupProgress.START:
return 0;
case SelfHostedSignupProgress.CREATED_CUSTOMER:
return 10;
case SelfHostedSignupProgress.CREATED_INTENT:
return 20;
case SelfHostedSignupProgress.CONFIRMED_INTENT:
return 30;
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return 50;
case SelfHostedSignupProgress.PAID:
return 90;
case SelfHostedSignupProgress.CREATED_LICENSE:
return 100;
default:
return 0;
}
}
interface Props {
desiredPlanName: string;
progressBar: number;
}
export default function Submitting(props: Props) {
const progress = useSelector(getSelfHostedSignupProgress);
const waitingExplanation = useConvertProgressToWaitingExplanation(progress, props.desiredPlanName);
const footer = (
<div className='ProcessPayment-progress'>
<div
className='ProcessPayment-progress-fill'
style={{width: `${props.progressBar}%`}}
/>
</div>
);
return (
<div className='submitting'>
<IconMessage
formattedTitle={(
<FormattedMessage
id='admin.billing.subscription.verifyPaymentInformation'
defaultMessage='Verifying your payment information'
/>
)}
formattedSubtitle={waitingExplanation}
icon={
<CreditCardSvg
width={444}
height={313}
/>
}
footer={footer}
className={'processing'}
/>
</div>
);
}

View File

@ -1,16 +0,0 @@
.SelfHostedPurchaseModal__success {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
}

View File

@ -1,58 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import './success_page.scss';
interface Props {
planName: string;
onClose: () => void;
}
export default function SuccessPage(props: Props) {
const title = (
<FormattedMessage
id={'admin.billing.subscription.subscribedSuccess'}
defaultMessage={'You\'re now subscribed to {productName}'}
values={{productName: props.planName}}
/>
);
const formattedSubtitle = (
<FormattedMessage
id={'self_hosted_signup.license_applied'}
defaultMessage={'Your {planName} license has now been applied. {planName} features are now available and ready to use.'}
values={{planName: props.planName}}
/>
);
const formattedBtnText = (
<FormattedMessage
id={'self_hosted_signup.close'}
defaultMessage={'Close'}
/>
);
return (
<div className='success'>
<IconMessage
className={'SelfHostedPurchaseModal__success'}
formattedTitle={title}
formattedSubtitle={formattedSubtitle}
testId='selfHostedPurchaseSuccess'
icon={
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
}
formattedButtonText={formattedBtnText}
buttonHandler={props.onClose}
/>
</div>
);
}

View File

@ -1,51 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import ExternalLink from 'components/external_link';
interface Props {
agreed: boolean;
setAgreed: (agreed: boolean) => void;
}
import {HostedCustomerLinks} from 'utils/constants';
export default function Terms(props: Props) {
return (
<div className='form-row'>
<div className='self-hosted-agreed-terms'>
<label>
<input
id='self_hosted_purchase_terms'
type='checkbox'
checked={props.agreed}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
props.setAgreed(e.target.checked);
}}
/>
<div>
<FormattedMessage
id='self_hosted_signup.disclaimer'
defaultMessage='I have read and agree to the <a>Enterprise Edition Subscription Terms</a>'
values={{
a: (chunks: React.ReactNode) => {
return (
<ExternalLink
href={HostedCustomerLinks.TERMS_AND_CONDITIONS}
location='self_hosted_purchase_modal_terms'
>
{chunks}
</ExternalLink>
);
},
}}
/>
</div>
</label>
</div>
</div>
);
}

View File

@ -1,14 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const SetPrefix = 'set_' as const;
export type SetAction<K extends string, V, P extends string=typeof SetPrefix> = {
type: `${P}${K}`;
data: V;
}
export type UnionSetActions<T> = {
[Key in Extract<keyof T, string>]: SetAction<Key, T[Key]>
}[Extract<keyof T, string>]

View File

@ -1,29 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect} from 'react';
// hack to disallow closing payment modal with escape.
// Wrapping the function in this way enables the
// somewhat common use case accidental modal open followed
// by immediate closing by pressing escape
function makeDisallowEscape() {
let hitOtherKey = false;
return function disallowEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && hitOtherKey) {
e.preventDefault();
e.stopPropagation();
}
hitOtherKey = true;
};
}
export default function useNoEscape() {
useEffect(() => {
const disallowEscape = makeDisallowEscape();
document.addEventListener('keydown', disallowEscape, true);
return () => {
document.removeEventListener('keydown', disallowEscape, true);
};
}, []);
}

View File

@ -1,23 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Elements} from '@stripe/react-stripe-js';
import type {Stripe} from '@stripe/stripe-js';
import React from 'react';
import {STRIPE_CSS_SRC} from 'components/payment_form/stripe';
interface Props {
children: React.ReactNode | React.ReactNodeArray;
stripeRef: React.MutableRefObject<Stripe | null>;
}
export default function StripeElementsProvider(props: Props) {
return (
<Elements
options={{fonts: [{cssSrc: STRIPE_CSS_SRC}]}}
stripe={props.stripeRef.current}
>
{props.children}
</Elements>
);
}

View File

@ -37,7 +37,19 @@ describe('components/widgets/links/UpgradeLink', () => {
});
test('should trigger telemetry call when button clicked', (done) => {
const store = mockStore({});
const mockWindowOpen = jest.fn();
global.window.open = mockWindowOpen;
const store = mockStore({
entities: {
general: {},
cloud: {
customer: {},
},
users: {
profiles: {},
},
},
});
const wrapper = mountWithIntl(
<Provider store={store}><UpgradeLink telemetryInfo='testing'/></Provider>,
);
@ -49,5 +61,6 @@ describe('components/widgets/links/UpgradeLink', () => {
done();
});
expect(wrapper).toMatchSnapshot();
expect(mockWindowOpen).toHaveBeenCalled();
});
});

View File

@ -3,16 +3,11 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import PurchaseModal from 'components/purchase_modal';
import {ModalIdentifiers} from 'utils/constants';
import './link.scss';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
export interface UpgradeLinkProps {
telemetryInfo?: string;
@ -22,26 +17,17 @@ export interface UpgradeLinkProps {
}
const UpgradeLink = (props: UpgradeLinkProps) => {
const dispatch = useDispatch();
const styleButton = props.styleButton ? ' style-button' : '';
const styleLink = props.styleLink ? ' style-link' : '';
const [openSalesLink] = useOpenSalesLink();
const handleLinkClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
if (props.telemetryInfo) {
trackEvent('upgrade_mm_cloud', props.telemetryInfo);
}
try {
dispatch(openModal({
modalId: ModalIdentifiers.CLOUD_PURCHASE,
dialogType: PurchaseModal,
dialogProps: {
callerCTA: props.telemetryInfo,
},
}));
} catch (error) {
// do nothing
}
openSalesLink();
};
const buttonText = (
<FormattedMessage

View File

@ -312,26 +312,9 @@
"admin.billing.history.title": "Billing History",
"admin.billing.history.total": "Total",
"admin.billing.history.transactions": "Transactions",
"admin.billing.payment_info_display.allCardsAccepted": "All major credit cards are accepted.",
"admin.billing.payment_info_display.noPaymentInfo": "There are currently no credit cards on file.",
"admin.billing.payment_info_display.savedPaymentDetails": "Your saved payment details",
"admin.billing.payment_info_edit.cancel": "Cancel",
"admin.billing.payment_info_edit.creditCardWarningDescription": "Your credit card will be charged based on the number of users you have at the end of the monthly billing cycle. ",
"admin.billing.payment_info_edit.creditCardWarningTitle": "NOTE: Your card will not be charged at this time",
"admin.billing.payment_info_edit.formError": "There are errors in the form above",
"admin.billing.payment_info_edit.save": "Save credit card",
"admin.billing.payment_info_edit.serverError": "Something went wrong while saving payment infomation",
"admin.billing.payment_info_edit.title": "Edit Payment Information",
"admin.billing.payment_info.add": "Add a Credit Card",
"admin.billing.payment_info.billingAddress": "Billing Address",
"admin.billing.payment_info.cardBrandAndDigits": "{brand} ending in {digits}",
"admin.billing.payment_info.cardExpiry": "Expires {month}/{year}",
"admin.billing.payment_info.creditCardAboutToExpire": "Your credit card is about to expire",
"admin.billing.payment_info.creditCardAboutToExpire.description": "Please update your payment information to avoid any disruption.",
"admin.billing.payment_info.title": "Payment Information",
"admin.billing.purchaseModal.editPaymentInfoButton": "Edit",
"admin.billing.purchaseModal.savedPaymentDetailsTitle": "Your saved payment details",
"admin.billing.subscription.billedFrom": "You'll be billed from: {beginDate}",
"admin.billing.subscription.byClickingYouAgree": "By clicking {buttonContent}, you agree to the <linkAgreement>{legalText}</linkAgreement>",
"admin.billing.subscription.cancelSubscriptionSection.contactUs": "Contact Us",
"admin.billing.subscription.cancelSubscriptionSection.description": "At this time, deleting a workspace can only be done with the help of a customer support representative.",
@ -344,12 +327,9 @@
"admin.billing.subscription.cloudTrial.daysLeftOnTrial": "There are {daysLeftOnTrial} days left on your free trial",
"admin.billing.subscription.cloudTrial.lastDay": "This is the last day of your free trial. Your access will expire on {userEndTrialDate} at {userEndTrialHour}.",
"admin.billing.subscription.cloudTrial.moreThan3Days": "Your trial has started! There are {daysLeftOnTrial} days left",
"admin.billing.subscription.cloudTrial.purchaseButton": "Purchase Now",
"admin.billing.subscription.cloudTrial.subscribeButton": "Upgrade Now",
"admin.billing.subscription.cloudTrialBadge.daysLeftOnTrial": "{daysLeftOnTrial} trial days left",
"admin.billing.subscription.cloudYearlyBadge": "Annual",
"admin.billing.subscription.complianceScreenFailed.button": "OK",
"admin.billing.subscription.complianceScreenFailed.title": "Your transaction is being reviewed",
"admin.billing.subscription.complianceScreenShippingSameAsBilling": "My shipping address is the same as my billing address",
"admin.billing.subscription.creditCardExpired": "Your credit card has expired. Update your payment information to avoid disruption.",
"admin.billing.subscription.creditCardHasExpired": "Your credit card has expired",
@ -365,20 +345,13 @@
"admin.billing.subscription.deleteWorkspaceSection.description": "Deleting {workspaceLink} is final and cannot be reversed.",
"admin.billing.subscription.deleteWorkspaceSection.title": "Delete your workspace",
"admin.billing.subscription.downgrading": "Downgrading your workspace",
"admin.billing.subscription.featuresAvailable": "{productName} features are now available and ready to use.",
"admin.billing.subscription.freeTrial.description": "Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.",
"admin.billing.subscription.freeTrial.lastDay.description": "Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.",
"admin.billing.subscription.freeTrial.lastDay.title": "Your free trial ends today",
"admin.billing.subscription.freeTrial.lessThan3Days.description": "Your free trial will end in {daysLeftOnTrial, number} {daysLeftOnTrial, plural, one {day} other {days}}. Add payment information to continue enjoying the benefits of Cloud Professional.",
"admin.billing.subscription.freeTrial.title": "You're currently on a free trial",
"admin.billing.subscription.goBackTryAgain": "Go back and try again",
"admin.billing.subscription.howItWorks": "See how billing works",
"admin.billing.subscription.invoice.next": "Next Invoice",
"admin.billing.subscription.LearnMore": "Learn more",
"admin.billing.subscription.mostRecentPaymentFailed": "Your most recent payment failed",
"admin.billing.subscription.nextBillingDate": "Starting from {date}, you will be billed for the {productName} plan. You can change your plan whenever you like and we will pro-rate the charges.",
"admin.billing.subscription.paymentFailed": "Payment failed. Please try again or contact support.",
"admin.billing.subscription.paymentVerificationFailed": "Sorry, the payment verification failed",
"admin.billing.subscription.planDetails.currentPlan": "Current Plan",
"admin.billing.subscription.planDetails.features.advanceTeamPermission": "Advanced team permissions",
"admin.billing.subscription.planDetails.features.autoComplianceExports": "Automated compliance exports",
@ -424,28 +397,17 @@
"admin.billing.subscription.privateCloudCard.freeTrial.description": "We love to work with our customers and their needs. Contact sales for subscription, billing or trial-specific questions.",
"admin.billing.subscription.privateCloudCard.freeTrial.title": "Questions about your trial?",
"admin.billing.subscription.privateCloudCard.upgradeNow": "Upgrade Now",
"admin.billing.subscription.proratedPayment.substitle": "Thank you for upgrading to {selectedProductName}. Check your workspace in a few minutes to access all the plan's features. You'll be charged a prorated amount for your {currentProductName} plan and {selectedProductName} plan based on the number of days left in the billing cycle and number of users you have.",
"admin.billing.subscription.proratedPayment.title": "You are now subscribed to {selectedProductName}",
"admin.billing.subscription.providePaymentDetails": "Provide your payment details",
"admin.billing.subscription.returnToTeam": "Return to {team}",
"admin.billing.subscription.stateprovince": "State/Province",
"admin.billing.subscription.subscribedSuccess": "You're now subscribed to {productName}",
"admin.billing.subscription.switchedToAnnual.title": "You're now switched to {selectedProductName} annual",
"admin.billing.subscription.title": "Subscription",
"admin.billing.subscription.updatePaymentInfo": "Update Payment Information",
"admin.billing.subscription.upgradedSuccess": "You're now upgraded to {productName}",
"admin.billing.subscription.userCount.tooltipText": "You must purchase at least the current number of active users.",
"admin.billing.subscription.userCount.tooltipTitle": "Current User Count",
"admin.billing.subscription.verifyPaymentInformation": "Verifying your payment information",
"admin.billing.subscription.viewBilling": "View Billing",
"admin.billing.subscriptions.billing_summary.explore_enterprise": "Explore Enterprise features",
"admin.billing.subscriptions.billing_summary.explore_enterprise.cta": "View all features",
"admin.billing.subscriptions.billing_summary.lastInvoice.approved": "Approved",
"admin.billing.subscriptions.billing_summary.lastInvoice.failed": "Failed",
"admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee": "Monthly Flat Fee",
"admin.billing.subscriptions.billing_summary.lastInvoice.paid": "Paid",
"admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges": "Partial charges",
"admin.billing.subscriptions.billing_summary.lastInvoice.pending": "Pending",
"admin.billing.subscriptions.billing_summary.lastInvoice.seatCount": " x {seats} seats",
"admin.billing.subscriptions.billing_summary.lastInvoice.seatCountPartial": "{seats} seats",
"admin.billing.subscriptions.billing_summary.lastInvoice.seeBillingHistory": "See Billing History",
@ -461,8 +423,6 @@
"admin.billing.subscriptions.billing_summary.try_enterprise": "Try Enterprise features for free",
"admin.billing.subscriptions.billing_summary.try_enterprise.cta": "Try free for {trialLength} days",
"admin.billing.subscriptions.billing_summary.upcomingInvoice.has_more_line_items": "And {count} more items",
"admin.billing.subscriptions.billing_summary.upgrade_professional": "Upgrade to the Professional Plan",
"admin.billing.subscriptions.billing_summary.upgrade_professional.cta": "Upgrade",
"admin.billing.trueUpReview.button_download": "Download Data",
"admin.billing.trueUpReview.button_share": "Share to Mattermost",
"admin.billing.trueUpReview.docsLinkCTA": "Learn more about true-up.",
@ -1302,7 +1262,6 @@
"admin.jobTable.statusPending": "Pending",
"admin.jobTable.statusSuccess": "Success",
"admin.jobTable.statusWarning": "Warning",
"admin.ldap_feature_discovery_cloud.call_to_action.primary": "Upgrade now",
"admin.ldap_feature_discovery_cloud.call_to_action.primary_sales": "Contact sales",
"admin.ldap_feature_discovery.call_to_action.primary": "Start trial",
"admin.ldap_feature_discovery.call_to_action.primary.cloudFree": "Try free for {trialLength} days",
@ -1457,7 +1416,6 @@
"admin.license.trialCard.description": "Your free trial will expire in **{daysCount} {daysCount, plural, one {day} other {days}}**. Visit our customer portal to purchase a license now to continue using Mattermost Professional and Enterprise features after trial ends.",
"admin.license.trialCard.description.expiringToday": "Your free trial expires **Today at {time}**. Visit our customer portal to purchase a license now to continue using Mattermost Professional and Enterprise features after trial ends",
"admin.license.trialCard.licenseExpiring": "Youre currently on a free trial of our Mattermost Enterprise license.",
"admin.license.trialCard.purchase": "Purchase",
"admin.license.trialCard.purchase_license": "Purchase a license",
"admin.license.trialUpgradeAndRequest.submit": "Upgrade Server And Start trial",
"admin.license.upgrade-and-trial-request.accept-terms-final-part": "Also, I agree to the terms of the Mattermost {eeModalTerms}. Upgrading will download the binary and update your Team Edition instance.",
@ -2403,7 +2361,6 @@
"admin.sidebar.oauth": "OAuth 2.0",
"admin.sidebar.openid": "OpenID Connect",
"admin.sidebar.password": "Password",
"admin.sidebar.payment_info": "Payment Information",
"admin.sidebar.permissions": "Permissions",
"admin.sidebar.plugins": "Plugins",
"admin.sidebar.posts": "Posts",
@ -3007,8 +2964,6 @@
"backstage_sidebar.integrations.oauthApps": "OAuth 2.0 Applications",
"backstage_sidebar.integrations.outgoing_webhooks": "Outgoing Webhooks",
"backstage_sidebar.integrations.outgoingOauthConnections": "Outgoing OAuth 2.0 Connections",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title": "Your annual subscription expires in {days} days. Please renew now to avoid any disruption",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired": "Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption",
"billing.subscription.info.mostRecentPaymentFailed": "Your most recent payment failed",
"billing.subscription.info.mostRecentPaymentFailed.description.mostRecentPaymentFailed": "It looks your most recent payment failed because the credit card on your account has expired. Please <link>update your payment information</link> to avoid any disruption.",
"bot.add.description": "Description",
@ -3271,37 +3226,15 @@
"claim.oauth_to_email.pwdNotMatch": "Passwords do not match.",
"claim.oauth_to_email.switchTo": "Switch {type} to Email and Password",
"claim.oauth_to_email.title": "Switch {type} Account to Email",
"cloud_annual_renewal_delinquency.banner.end_user.message": "Your annual subscription has expired. Please contact your System Admin to keep this workspace",
"cloud_annual_renewal_delinquency.banner.message": "Your annual subscription has expired. Please renew now to keep this workspace",
"cloud_annual_renewal.banner.buttonText.contactSales": "Contact Sales",
"cloud_annual_renewal.banner.buttonText.renew": "Renew",
"cloud_annual_renewal.banner.message.30": "Your annual subscription expires in {days} days. Please renew to avoid any disruption.",
"cloud_annual_renewal.banner.message.60": "Your annual subscription expires in {days} days. Please renew to avoid any disruption.",
"cloud_annual_renewal.banner.message.7": "Your annual subscription expires in {days} days. Failure to renew will result in your workspace being deleted.",
"cloud_archived.error.access": "Permalink belongs to a message that has been archived because of {planName} limits. Upgrade to access message again.",
"cloud_archived.error.title": "Message Archived",
"cloud_billing_history_modal.title": "Invoice(s)",
"cloud_billing.nudge_to_paid.contact_sales": "Contact sales",
"cloud_billing.nudge_to_paid.description": "Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.",
"cloud_billing.nudge_to_paid.learn_more": "Upgrade",
"cloud_billing.nudge_to_paid.title": "Upgrade to paid plan to keep your workspace",
"cloud_billing.nudge_to_paid.view_plans": "View plans",
"cloud_delinquency.banner.buttonText": "Update billing now",
"cloud_delinquency.cc_modal.card.reactivate": "Reactivate",
"cloud_delinquency.cc_modal.card.totalOwed": "Total owed",
"cloud_delinquency.cc_modal.card.viewBreakdown": "View breakdown",
"cloud_delinquency.cc_modal.disclaimer": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}",
"cloud_delinquency.cc_modal.disclaimer_with_upgrade_info": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. You'll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}",
"cloud_signup.signup_consequences": "Your credit card will be charged today. <a>See how billing works.</a>",
"cloud_subscribe.contact_support": "Compare plans",
"cloud_upgrade.error_min_seats": "Minimum of 10 seats required",
"cloud.fetch_error": "Error fetching billing data. Please try again later.",
"cloud.fetch_error.retry": "Retry",
"cloud.invoice_pdf_preview.download": "<downloadLink>Download</downloadLink> this page for your records",
"cloud.renewal.andMoreItems": "+ {count} more items",
"cloud.renewal.renew": "Renew",
"cloud.renewal.tobepaid": "To be paid on {date}",
"cloud.renewal.viewInvoice": "View Invoice",
"cloud.startTrial.modal.btn": "Start trial",
"collapsed_reply_threads_modal.confirm": "Got it",
"collapsed_reply_threads_modal.description": "Threads have been revamped to help you create organized conversation around specific messages. Now, channels will appear less cluttered as replies are collapsed under the original message, and all the conversations you're following are available in your **Threads** view. Take the tour to see what's new.",
@ -4498,7 +4431,6 @@
"payment_form.no_billing_address": "No billing address added",
"payment_form.no_credit_card": "No credit card added",
"payment_form.saved_payment_method": "Saved Payment Method",
"payment_form.shipping_address": "Shipping Address",
"payment_form.zipcode": "Zip/Postal Code",
"payment.card_number": "Card Number",
"payment.field_required": "This field is required",
@ -4639,15 +4571,9 @@
"postypes.custom_open_plugin_install_post_rendered.plugins_installed": "{pluginName} is now installed.",
"postypes.custom_open_plugin_install_post_rendered.plugins_instructions": "Install the apps or visit the <marketplaceLink>Marketplace</marketplaceLink> to view all plugins.",
"postypes.custom_open_pricing_modal_post_renderer.and": "and",
"postypes.custom_open_pricing_modal_post_renderer.availableOn": " - available on the {feature}",
"postypes.custom_open_pricing_modal_post_renderer.downgradeNotfication": "{userRequests} requested to revert the workspace to a paid plan",
"postypes.custom_open_pricing_modal_post_renderer.learn_trial": "Learn more about trial",
"postypes.custom_open_pricing_modal_post_renderer.members": "{members} members",
"postypes.custom_open_pricing_modal_post_renderer.membersThatRequested": "Members that requested ",
"postypes.custom_open_pricing_modal_post_renderer.unknown": "@unknown",
"postypes.custom_open_pricing_modal_post_renderer.upgrade_professional": "Upgrade to Professional",
"postypes.custom_open_pricing_modal_post_renderer.userRequests": "{userRequests} requested access to this feature",
"postypes.custom_open_pricing_modal_post_renderer.view_options": "View upgrade options",
"pricing_modal.addons.dedicatedDB": "Dedicated database",
"pricing_modal.addons.dedicatedDeployment": "Dedicated virtual secure cloud deployment (Cloud)",
"pricing_modal.addons.dedicatedEncryption": "Dedicated encryption keys",
@ -4662,16 +4588,11 @@
"pricing_modal.briefing.enterprise.groupSync": "AD/LDAP group sync",
"pricing_modal.briefing.enterprise.mobileSecurity": "Advanced mobile security via ID-only push notifications",
"pricing_modal.briefing.enterprise.rolesAndPermissions": "Advanced roles and permissions",
"pricing_modal.briefing.free.gitLabGitHubGSuite": "GitLab, GitHub, and GSuite SSO",
"pricing_modal.briefing.free.noLimitBoards": "Unlimited board cards",
"pricing_modal.briefing.free.oneTeamPerWorkspace": "One team per workspace",
"pricing_modal.briefing.free.recentMessageBoards": "Access to {messages} most recent messages",
"pricing_modal.briefing.fullMessageAndHistory": "Full message and file history",
"pricing_modal.briefing.professional.advancedPlaybook": "Advanced Playbook workflows with retrospectives",
"pricing_modal.briefing.professional.messageBoardsIntegrationsCalls": "Unlimited access to messages and files",
"pricing_modal.briefing.professional.unLimitedTeams": "Unlimited teams",
"pricing_modal.briefing.ssoWithGitLab": "SSO with Gitlab",
"pricing_modal.briefing.storageStarter": "{storage} file storage limit",
"pricing_modal.briefing.title": "Top features",
"pricing_modal.briefing.title_large_scale": "Large scale collaboration",
"pricing_modal.briefing.title_no_limit": "No limits on your teams usage",
@ -4679,7 +4600,6 @@
"pricing_modal.briefing.unlimitedWorkspaceTeams": "Unlimited workspace teams",
"pricing_modal.btn.contactSales": "Contact Sales",
"pricing_modal.btn.contactSalesForQuote": "Contact Sales",
"pricing_modal.btn.contactSupport": "Contact Support",
"pricing_modal.btn.downgrade": "Downgrade",
"pricing_modal.btn.purchase": "Purchase",
"pricing_modal.btn.switch_to_annual": "Switch to annual billing",
@ -4688,7 +4608,6 @@
"pricing_modal.btn.upgrade": "Upgrade",
"pricing_modal.btn.viewPlans": "View plans",
"pricing_modal.contact_us": "Contact us",
"pricing_modal.extra_briefing.cloud.free.calls": "Group calls of up to 8 people, 1:1 calls, and screen share",
"pricing_modal.extra_briefing.enterprise.playBookAnalytics": "Playbook analytics dashboard",
"pricing_modal.extra_briefing.free.calls": "Voice calls and screen share",
"pricing_modal.extra_briefing.professional.guestAccess": "Guest access with MFA enforcement",
@ -4697,7 +4616,6 @@
"pricing_modal.interested_self_hosting": "Interested in self-hosting?",
"pricing_modal.learn_more": "Learn more",
"pricing_modal.lookingForCloudOption": "Looking for a cloud option?",
"pricing_modal.lookingToSelfHost": "Looking to self-host?",
"pricing_modal.noitfy_cta.request": "Request admin to upgrade",
"pricing_modal.noitfy_cta.request_success": "Request sent",
"pricing_modal.or": "or",
@ -4705,7 +4623,6 @@
"pricing_modal.planDisclaimer.free": "This plan has data restrictions.",
"pricing_modal.planLabel.currentPlan": "CURRENT PLAN",
"pricing_modal.planLabel.currentPlanMonthly": "CURRENTLY ON MONTHLY BILLING",
"pricing_modal.planLabel.mostPopular": "MOST POPULAR",
"pricing_modal.planSummary.enterprise": "Administration, security, and compliance for large teams",
"pricing_modal.planSummary.free": "Increased productivity for small teams",
"pricing_modal.planSummary.professional": "Scalable solutions {br} for growing teams",
@ -4854,49 +4771,13 @@
"select_team.icon": "Select Team Icon",
"select_team.join.icon": "Join Team Icon",
"select_team.private.icon": "Private Team",
"self_hosted_expansion_rhs_card_add_new_seats": "Add new seats",
"self_hosted_expansion_rhs_card_cost_per_user_breakdown": "{costPerUser} x {monthsUntilExpiry} months",
"self_hosted_expansion_rhs_card_cost_per_user_title": "Cost per user",
"self_hosted_expansion_rhs_card_license_date": "{startsAt} - {endsAt}",
"self_hosted_expansion_rhs_card_licensed_seats": "{licensedSeats} LICENSES SEATS",
"self_hosted_expansion_rhs_card_maximum_seats_warning": "{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats",
"self_hosted_expansion_rhs_card_must_add_seats_warning": "{warningIcon} You must add a seat to continue",
"self_hosted_expansion_rhs_card_must_purchase_enough_seats": "{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license",
"self_hosted_expansion_rhs_card_total_prorated_warning": "The total will be prorated",
"self_hosted_expansion_rhs_card_total_title": "Total",
"self_hosted_expansion_rhs_complete_button": "Complete purchase",
"self_hosted_expansion_rhs_credit_card_charge_today_warning": "Your credit card will be charged today.<see_how_billing_works>See how billing works.</see_how_billing_works>",
"self_hosted_expansion_rhs_license_summary_title": "License Summary",
"self_hosted_expansion.close": "Close",
"self_hosted_expansion.contact_support": "Contact Support",
"self_hosted_expansion.expand_success": "You've successfully updated your license seat count",
"self_hosted_expansion.expansion_modal.title": "Provide your payment details",
"self_hosted_expansion.license_applied": "The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the <billing>Billing section</billing> of the system console.",
"self_hosted_expansion.paymentFailed": "Payment failed. Please try again or contact support.",
"self_hosted_expansion.try_again": "Try again",
"self_hosted_signup.air_gapped_content": "It appears that your instance is air-gapped, or it may not be connected to the internet. To purchase a license, please visit",
"self_hosted_signup.air_gapped_title": "Purchase through the customer portal",
"self_hosted_signup.close": "Close",
"self_hosted_signup.contact_sales": "Contact Sales",
"self_hosted_signup.cta": "Upgrade",
"self_hosted_signup.disclaimer": "I have read and agree to the <a>Enterprise Edition Subscription Terms</a>",
"self_hosted_signup.error_invalid_number": "Enter a valid number of seats",
"self_hosted_signup.error_max_seats": " license purchase only supports purchases up to {num} seats",
"self_hosted_signup.error_min_seats": "Your workspace currently has {num} users",
"self_hosted_signup.failed_export.subtitle": "We will check things on our side and get back to you within 3 days once your license is approved. In the meantime, please feel free to continue using the free version of our product.",
"self_hosted_signup.failed_export.title": "Your transaction is being reviewed",
"self_hosted_signup.license_applied": "Your {planName} license has now been applied. {planName} features are now available and ready to use.",
"self_hosted_signup.line_item_subtotal": "{num} seats × 12 mo.",
"self_hosted_signup.organization": "Organization Name",
"self_hosted_signup.progress_step.applying_license": "Applying your {planName} license to your Mattermost instance",
"self_hosted_signup.progress_step.submitting_payment": "Submitting payment information",
"self_hosted_signup.progress_step.verifying_payment": "Verifying payment details",
"self_hosted_signup.purchase_in_progress.by_other": "{username} is currently attempting to purchase a paid license.",
"self_hosted_signup.purchase_in_progress.by_self": "You are currently attempting to purchase in another browser window. Complete your purchase or close the other window(s).",
"self_hosted_signup.purchase_in_progress.by_self_restart": "If you believe this to be a mistake, restart your purchase.",
"self_hosted_signup.purchase_in_progress.reset": "Restart purchase",
"self_hosted_signup.purchase_in_progress.title": "Purchase in progress",
"self_hosted_signup.retry": "Try again",
"self_hosted_signup.screening_description": "We will check things on our side and get back to you within 3 days once your license is approved. In the meantime, please feel free to continue using the free version of our product.",
"self_hosted_signup.screening_title": "Your transaction is being reviewed",
"self_hosted_signup.seats": "Seats",
@ -5896,8 +5777,6 @@
"workspace_limits.archived_file.archived_compact": "(archived)",
"workspace_limits.archived_file.tooltip_description": "Your workspace has hit the file storage limit of {storageLimit}. To view this again, upgrade to a paid plan",
"workspace_limits.archived_file.tooltip_title": "Unarchive this file by upgrading",
"workspace_limits.banner_upgrade_reason.free": "Your workspace has exceeded {planName} plan data limits. Upgrade to a paid plan for additional capacity.",
"workspace_limits.banner_upgrade.free": "Upgrade to one of our paid plans to avoid {planName} plan data limits",
"workspace_limits.file_storage": "File storage",
"workspace_limits.file_storage.short": "Files",
"workspace_limits.file_storage.short.usage": "{actual} / {limit}",

View File

@ -11,7 +11,6 @@ import ChannelMembersModal from 'components/channel_members_modal';
import {openPricingModal} from 'components/global_header/right_controls/plan_upgrade_button';
import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta';
import PostMessagePreview from 'components/post_view/post_message_preview';
import PurchaseModal from 'components/purchase_modal';
import StartTrialFormModal from 'components/start_trial_form_modal';
import ThreadViewer from 'components/threading/thread_viewer';
import Timestamp from 'components/timestamp';
@ -82,7 +81,6 @@ window.openPricingModal = () => openPricingModal;
// guarantee better compatibility.
window.Components = {
Textbox,
PurchaseModal,
Timestamp,
ChannelInviteModal,
ChannelMembersModal,