diff --git a/api4/cloud.go b/api4/cloud.go
index 32cb798799..93ca4c4a22 100644
--- a/api4/cloud.go
+++ b/api4/cloud.go
@@ -35,6 +35,9 @@ func (api *API) InitCloud() {
api.BaseRoutes.Cloud.Handle("/subscription", api.ApiSessionRequired(getSubscription)).Methods("GET")
api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.ApiSessionRequired(getInvoicesForSubscription)).Methods("GET")
api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.ApiSessionRequired(getSubscriptionInvoicePDF)).Methods("GET")
+
+ // POST /api/v4/cloud/webhook
+ api.BaseRoutes.Cloud.Handle("/webhook", api.CloudApiKeyRequired(handleCWSWebhook)).Methods("POST")
}
func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -324,3 +327,34 @@ func getSubscriptionInvoicePDF(c *Context, w http.ResponseWriter, r *http.Reques
return
}
}
+
+func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
+ if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud {
+ c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.license_error", nil, "", http.StatusNotImplemented)
+ return
+ }
+
+ bodyBytes, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer r.Body.Close()
+
+ var event *model.CWSWebhookPayload
+ if err = json.Unmarshal(bodyBytes, &event); err != nil {
+ c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ switch event.Event {
+ case model.EventTypeFailedPayment:
+ if nErr := c.App.SendPaymentFailedEmail(event.FailedPayment); nErr != nil {
+ c.Err = nErr
+ return
+ }
+
+ }
+
+ ReturnStatusOK(w)
+}
diff --git a/app/app_iface.go b/app/app_iface.go
index eba57937ea..7abdd2e0dc 100644
--- a/app/app_iface.go
+++ b/app/app_iface.go
@@ -893,6 +893,7 @@ type AppIface interface {
SendEphemeralPost(userId string, post *model.Post) *model.Post
SendNotifications(post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error)
SendPasswordReset(email string, siteURL string) (bool, *model.AppError)
+ SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError
ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string)
ServePluginRequest(w http.ResponseWriter, r *http.Request)
Session() *model.Session
diff --git a/app/cloud.go b/app/cloud.go
index 6662ba9bf2..a34799d0f3 100644
--- a/app/cloud.go
+++ b/app/cloud.go
@@ -4,9 +4,20 @@
package app
import (
+ "github.com/mattermost/mattermost-server/v5/mlog"
"github.com/mattermost/mattermost-server/v5/model"
)
+func (a *App) getSysAdminsEmailRecipients() ([]*model.User, *model.AppError) {
+ userOptions := &model.UserGetOptions{
+ Page: 0,
+ PerPage: 100,
+ Role: model.SYSTEM_ADMIN_ROLE_ID,
+ Inactive: false,
+ }
+ return a.GetUsers(userOptions)
+}
+
func (a *App) CheckAndSendUserLimitWarningEmails() *model.AppError {
if a.Srv().License() == nil || (a.Srv().License() != nil && !*a.Srv().License().Features.Cloud) {
// Not cloud instance, do nothing
@@ -30,13 +41,7 @@ func (a *App) CheckAndSendUserLimitWarningEmails() *model.AppError {
if remainingUsers > 0 {
return nil
}
- userOptions := &model.UserGetOptions{
- Page: 0,
- PerPage: 100,
- Role: model.SYSTEM_ADMIN_ROLE_ID,
- Inactive: false,
- }
- sysAdmins, err := a.GetUsers(userOptions)
+ sysAdmins, err := a.getSysAdminsEmailRecipients()
if err != nil {
return err
}
@@ -55,3 +60,18 @@ func (a *App) CheckAndSendUserLimitWarningEmails() *model.AppError {
}
return nil
}
+
+func (a *App) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError {
+ sysAdmins, err := a.getSysAdminsEmailRecipients()
+ if err != nil {
+ return err
+ }
+
+ for _, admin := range sysAdmins {
+ _, err := a.Srv().EmailService.SendPaymentFailedEmail(admin.Email, admin.Locale, failedPayment, *a.Config().ServiceSettings.SiteURL)
+ if err != nil {
+ a.Log().Error("Error sending payment failed email", mlog.Err(err))
+ }
+ }
+ return nil
+}
diff --git a/app/email.go b/app/email.go
index 3cf2319854..2236f5ac04 100644
--- a/app/email.go
+++ b/app/email.go
@@ -766,3 +766,25 @@ func (es *EmailService) SendSuspensionEmailToSupport(email string, installationI
return true, nil
}
+
+func (es *EmailService) SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, siteURL string) (bool, *model.AppError) {
+ T := utils.GetUserTranslations(locale)
+
+ subject := T("api.templates.payment_failed.subject")
+
+ bodyPage := es.newEmailTemplate("payment_failed_body", locale)
+ bodyPage.Props["SiteURL"] = siteURL
+ bodyPage.Props["Title"] = T("api.templates.payment_failed.title")
+ bodyPage.Props["Info1"] = T("api.templates.payment_failed.info1", map[string]interface{}{"CardBrand": failedPayment.CardBrand, "LastFour": failedPayment.LastFour})
+ bodyPage.Props["Info2"] = T("api.templates.payment_failed.info2")
+ bodyPage.Props["Info3"] = T("api.templates.payment_failed.info3")
+ bodyPage.Props["Button"] = T("api.templates.over_limit_fix_now")
+
+ bodyPage.Props["FailedReason"] = failedPayment.FailureMessage
+
+ if err := es.sendMail(email, subject, bodyPage.Render()); err != nil {
+ return false, model.NewAppError("SendPaymentFailedEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Message, http.StatusInternalServerError)
+ }
+
+ return true, nil
+}
diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go
index 5607e23a25..acf02250fe 100644
--- a/app/opentracing/opentracing_layer.go
+++ b/app/opentracing/opentracing_layer.go
@@ -13088,6 +13088,28 @@ 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) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId string, destinationPluginId string) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ServeInterPluginRequest")
diff --git a/i18n/en.json b/i18n/en.json
index b7aacbe285..a3bba9f6d0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -2854,6 +2854,26 @@
"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 subscription to Mattermost Cloud, 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 Cloud"
+ },
+ {
+ "id": "api.templates.payment_failed.title",
+ "translation": "Failed Payment"
+ },
{
"id": "api.templates.post_body.button",
"translation": "Go To Post"
diff --git a/model/cloud.go b/model/cloud.go
index e4fddbcde8..efd89762f7 100644
--- a/model/cloud.go
+++ b/model/cloud.go
@@ -3,6 +3,10 @@
package model
+const (
+ EventTypeFailedPayment = "failed-payment"
+)
+
// Product model represents a product on the cloud system.
type Product struct {
ID string `json:"id"`
@@ -112,3 +116,14 @@ type InvoiceLineItem struct {
Type string `json:"type"`
Metadata map[string]interface{} `json:"metadata"`
}
+
+type CWSWebhookPayload struct {
+ Event string `json:"event"`
+ FailedPayment *FailedPayment `json:"failed_payment"`
+}
+
+type FailedPayment struct {
+ CardBrand string `json:"card_brand"`
+ LastFour int `json:"last_four"`
+ FailureMessage string `json:"failure_message"`
+}
diff --git a/templates/payment_failed_body.html b/templates/payment_failed_body.html
new file mode 100644
index 0000000000..3fe30e9c0e
--- /dev/null
+++ b/templates/payment_failed_body.html
@@ -0,0 +1,93 @@
+{{define "payment_failed_body"}}
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ .Props.Title }}
+ |
+
+
+
+ {{ .Props.Info1 }}
+ {{.Props.Info2}} {{.Props.FailedReason}}
+ {{.Props.Info3}}
+
+ {{ .Props.Button }}
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+ {{.Props.Organization}}
+ {{.Props.Footer}}
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+{{end}}