[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>
Этот коммит содержится в:
@@ -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
|
||||
|
||||
34
app/cloud.go
34
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
|
||||
}
|
||||
|
||||
22
app/email.go
22
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
20
i18n/en.json
20
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"
|
||||
|
||||
@@ -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
Обычный файл
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}}
|
||||
Ссылка в новой задаче
Block a user