[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 <maria.nunez@mattermost.com>

* Update api4/cloud.go

Co-authored-by: Maria A Nunez <maria.nunez@mattermost.com>

* Update api4/cloud.go

Co-authored-by: Maria A Nunez <maria.nunez@mattermost.com>

* PR changes

* Update app/email.go

Co-authored-by: Mario de Frutos Dieguez <mario@defrutos.org>

* Close body in proper spot

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Maria A Nunez <maria.nunez@mattermost.com>
Co-authored-by: Mario de Frutos Dieguez <mario@defrutos.org>
Этот коммит содержится в:
Nick Misasi
2020-11-25 15:45:15 -05:00
коммит произвёл GitHub
родитель a87e8d1c20
Коммит 9b6ee63a1e
8 изменённых файлов: 234 добавлений и 7 удалений

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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")

Просмотреть файл

@@ -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"

Просмотреть файл

@@ -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"`
}

93
templates/payment_failed_body.html Обычный файл
Просмотреть файл

@@ -0,0 +1,93 @@
{{define "payment_failed_body"}}
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="margin-top: 20px; line-height: 1.7; color: #555;font-family: Arial; font-style: normal; font-weight: bold;">
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width: 660px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF;">
<tr>
<td style="border: 1px solid #ddd;">
<table align="left" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="padding: 20px 20px 10px; text-align:left;">
<img src="{{.Props.SiteURL}}/static/images/logo-email.png" style="opacity: 0.5"
width="130px" alt="">
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="border-collapse: collapse; max-width: 443px">
<tr style="width: 443px">
<td>
<table border="0" cellpadding="0" cellspacing="0"
style="padding: 20px 0 0; text-align: center; margin: 0 auto">
<tr>
<td style="padding: 0 0 64px;">
<table align="center" border="0" cellpadding="0" cellspacing="0"
width="371px">
<tr>
<td>
<h2
style="margin-bottom: 0; text-align: center; color: #3D3C40; font-weight: bold; margin-top: 10px; line-height: 32px; font-size: 28px; font-family: Arial">
{{ .Props.Title }}</h2>
</td>
</tr>
</table>
<p
style="font-weight: normal; color: #3D3C40; font-size: 16px; line-height: 24px; font-family: Arial">
{{ .Props.Info1 }}</p>
<p style="margin-top: 16px; margin-bottom: 24px; font-weight: bold; font-size: 16px; line-height: 24px; color: #3D3C40; font-family: Arial">{{.Props.Info2}}<br>{{.Props.FailedReason}}</p>
<p style="margin-top: 24px; margin-bottom: 24px; font-weight: normal; font-size: 16px; line-height: 24px; color: #3D3C40; font-family: Arial">{{.Props.Info3}}</p>
<p style="margin: 31px 0 31px">
<a href="{{.Props.SiteURL}}/admin_console/billing/subscription"
target="_blank"
style="font-weight: normal; border-radius: 4px; background: #166DE0; color: #fff;outline: none; min-width: 200px; padding: 11px 24px 11px 24px; font-size: 16px; font-family: Arial; cursor: pointer; -webkit-appearance: none;text-decoration: none; margin-top: 16px;">{{ .Props.Button }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspcing="0" width="612px">
<tr>
<td style="border-bottom: 1px solid #E5E5E5"></td>
</tr>
</table>
<table align="left" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width: 443px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF; border-collapse: collapse;">
<tr>
<td style="font-family: Arial; padding: 20px 20px 24px 48px; text-align:left;">
<h3 style="margin-bottom: 0;">Questions?</h3>
<p style="font-weight: normal; margin-top: 0;">{{ .Props.EmailUs }} <a
href="mailto:feedback@mattermost.com">feedback@mattermost.com</a></p>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspcing="0" width="612px">
<tr>
<td style="border-bottom: 1px solid #E5E5E5"></td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="font-family: Arial; line-height: 16px; font-size: 12px; background: #FFF;">
<tr>
<td
style="font-weight: normal; text-align: center;color: #AAA;font-size: 11px;padding-bottom: 10px;padding: 0;line-height: 16px;padding-bottom: 48px;padding-top: 4px;">
<p>
{{.Props.Organization}}<br>
{{.Props.Footer}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{end}}