From 9b6ee63a1e9c94e3743065768cd26ee29d9a2e89 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 25 Nov 2020 15:45:15 -0500 Subject: [PATCH] [MM-29845] Add CWS Webhook endpoint and payment failed email (#16351) * Add a new handler to allow authentication via CWS API Key * Make error better * Add tests and cases for new handler functions * Move some code around * Add test for GetCloudSession function * unset the env after test completion * Remove white space * Add CWS Webhook endpoint and email code * handle returned errors from email sending function * Change FailureCode to FailureMessage * Remove unnecessary translations * Fix translations * Forgot to add template * Update api4/cloud.go Co-authored-by: Maria A Nunez * Update api4/cloud.go Co-authored-by: Maria A Nunez * Update api4/cloud.go Co-authored-by: Maria A Nunez * PR changes * Update app/email.go Co-authored-by: Mario de Frutos Dieguez * Close body in proper spot Co-authored-by: Mattermod Co-authored-by: Maria A Nunez Co-authored-by: Mario de Frutos Dieguez --- api4/cloud.go | 34 ++++++++++ app/app_iface.go | 1 + app/cloud.go | 34 +++++++--- app/email.go | 22 +++++++ app/opentracing/opentracing_layer.go | 22 +++++++ i18n/en.json | 20 ++++++ model/cloud.go | 15 +++++ templates/payment_failed_body.html | 93 ++++++++++++++++++++++++++++ 8 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 templates/payment_failed_body.html 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 }} +

+
+
+ + + + +
+ + + + +
+

Questions?

+

{{ .Props.EmailUs }} feedback@mattermost.com

+
+ + + + +
+ + + + + +
+

+ {{.Props.Organization}}
+ {{.Props.Footer}} +

+
+
+
+ +{{end}}