From b6e8d66a86426049340e31e24dcc7d59909fa618 Mon Sep 17 00:00:00 2001 From: remoterami <142154971+remoterami@users.noreply.github.com> Date: Thu, 16 Jan 2025 14:12:39 +0100 Subject: [PATCH] feat: payment issue notification service --- backend/cmd/user_service/main.go | 1 + ...cription_payment_issue_notification_ts.sql | 19 ++ backend/pkg/commons/mail/mail.go | 2 +- backend/pkg/commons/utils/products.go | 37 ++- .../userservice/subscription_end_reminder.go | 281 ++++++++++++++++++ 5 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 backend/pkg/commons/db/migrations/postgres/20250115144049_users_subscription_payment_issue_notification_ts.sql create mode 100644 backend/pkg/userservice/subscription_end_reminder.go diff --git a/backend/cmd/user_service/main.go b/backend/cmd/user_service/main.go index 640daa5f8..5e4756234 100644 --- a/backend/cmd/user_service/main.go +++ b/backend/cmd/user_service/main.go @@ -103,4 +103,5 @@ func Init() { log.Infof("starting user service") go userservice.StripeEmailUpdater() go userservice.CheckMobileSubscriptions() + go userservice.SubscriptionEndReminder() } diff --git a/backend/pkg/commons/db/migrations/postgres/20250115144049_users_subscription_payment_issue_notification_ts.sql b/backend/pkg/commons/db/migrations/postgres/20250115144049_users_subscription_payment_issue_notification_ts.sql new file mode 100644 index 000000000..e721a7bb7 --- /dev/null +++ b/backend/pkg/commons/db/migrations/postgres/20250115144049_users_subscription_payment_issue_notification_ts.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE users_app_subscriptions +ADD COLUMN IF NOT EXISTS payment_issues_mail_ts TIMESTAMP WITHOUT TIME ZONE; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE users_stripe_subscriptions +ADD COLUMN IF NOT EXISTS payment_issues_mail_ts TIMESTAMP WITHOUT TIME ZONE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE users_app_subscriptions +DROP COLUMN IF EXISTS payment_issues_mail_ts; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE users_stripe_subscriptions +DROP COLUMN IF EXISTS payment_issues_mail_ts; +-- +goose StatementEnd \ No newline at end of file diff --git a/backend/pkg/commons/mail/mail.go b/backend/pkg/commons/mail/mail.go index 6ccd2b2b1..8be54556f 100644 --- a/backend/pkg/commons/mail/mail.go +++ b/backend/pkg/commons/mail/mail.go @@ -34,7 +34,7 @@ func SendHTMLMail(to, subject string, msg types.Email, attachment []types.EmailA if utils.Config.Frontend.Mail.SMTP.User != "" { headers := "MIME-version: 1.0;\nContent-Type: text/html;" body.Write([]byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n%s\r\n", to, subject, headers))) - err = renderer.Execute(&body, MailTemplate{Mail: msg, Domain: utils.Config.Frontend.SiteDomain}) + err = renderer.ExecuteTemplate(&body, "layout", MailTemplate{Mail: msg, Domain: utils.Config.Frontend.SiteDomain}) if err != nil { return fmt.Errorf("error rendering mail template: %w", err) } diff --git a/backend/pkg/commons/utils/products.go b/backend/pkg/commons/utils/products.go index af855aedb..0abc768fb 100644 --- a/backend/pkg/commons/utils/products.go +++ b/backend/pkg/commons/utils/products.go @@ -5,6 +5,9 @@ const GROUP_MOBILE = "mobile" const GROUP_ADDON = "addon" var ProductsGroups = map[string]string{ + "sapphire": GROUP_API, + "emerald": GROUP_API, + "diamond": GROUP_API, "plankton": GROUP_MOBILE, "goldfish": GROUP_MOBILE, "whale": GROUP_MOBILE, @@ -57,6 +60,24 @@ func EffectiveProductId(productId string) string { func EffectiveProductName(productId string) string { productId = EffectiveProductId(productId) switch productId { + case "sapphire": + return "Sapphire" + case "emerald": + return "Emerald" + case "diamond": + return "Sapphire" + case "iron": + return "Iron" + case "iron.yearly": + return "Iron (yearly)" + case "silver": + return "Silver" + case "silver.yearly": + return "Silver (yearly)" + case "gold": + return "Gold" + case "gold.yearly": + return "Gold (yearly)" case "plankton": return "Plankton" case "goldfish": @@ -73,8 +94,14 @@ func EffectiveProductName(productId string) string { return "Guppy (yearly)" case "dolphin.yearly": return "Dolphin (yearly)" - case "orca.yearly": - return "Orca (yearly)" + case "vdb_addon_1k": + return "1,000 dashboard validators Add-On" + case "vdb_addon_1k.yearly": + return "1,000 dashboard validators Add-On (yearly)" + case "vdb_addon_10k": + return "10,000 dashboard validators Add-On" + case "vdb_addon_10k.yearly": + return "10,000 dashboard validators Add-On) (yearly)" default: return "" } @@ -82,6 +109,12 @@ func EffectiveProductName(productId string) string { func PriceIdToProductId(priceId string) string { switch priceId { + case Config.Frontend.Stripe.Sapphire: + return "sapphire" + case Config.Frontend.Stripe.Emerald: + return "emerald" + case Config.Frontend.Stripe.Diamond: + return "diamond" case Config.Frontend.Stripe.Plankton: return "plankton" case Config.Frontend.Stripe.Goldfish: diff --git a/backend/pkg/userservice/subscription_end_reminder.go b/backend/pkg/userservice/subscription_end_reminder.go new file mode 100644 index 000000000..35d33f243 --- /dev/null +++ b/backend/pkg/userservice/subscription_end_reminder.go @@ -0,0 +1,281 @@ +package userservice + +import ( + "fmt" + "html/template" + "time" + + "github.com/doug-martin/goqu/v9" + t "github.com/gobitfly/beaconchain/pkg/api/types" + "github.com/gobitfly/beaconchain/pkg/commons/db" + "github.com/gobitfly/beaconchain/pkg/commons/log" + "github.com/gobitfly/beaconchain/pkg/commons/mail" + "github.com/gobitfly/beaconchain/pkg/commons/services" + "github.com/gobitfly/beaconchain/pkg/commons/types" + "github.com/gobitfly/beaconchain/pkg/commons/utils" + "golang.org/x/sync/errgroup" +) + +type GraceSubscription struct { + Id string + ProductName string + End time.Time + Store t.ProductStore +} + +type ExpiredSubsInfo struct { + Email string + PremiumSubs []GraceSubscription + AddonSubs []GraceSubscription + ApiSubs []GraceSubscription +} + +const reminderFrequency = utils.Week +const gracePeriod = utils.Week * 2 + +func SubscriptionEndReminder() { + for { + start := time.Now() + + // get all subscriptions running on grace period which haven't been warned recently + gracePeriodSubscriptions, err := getPendingGracePeriodSubscriptionsByUser() + if err != nil { + log.Error(err, "error getting subscriptions in grace period", 0) + time.Sleep(time.Second * 10) + continue + } + + mailsSent := 0 + var premiumAddonIds, apiIds []string + for userId, subsInfo := range gracePeriodSubscriptions { + // send email + // not checking/increasing daily rate limit here + email := types.Email{ + Body: formatEmail(subsInfo), + Title: "Subscription payment issue", + SubscriptionManageURL: "beaconcha.in", + } + if err = mail.SendHTMLMail(subsInfo.Email, "beaconcha.in - Subscription payment issue", email, []types.EmailAttachment{}); err != nil { + log.Error(err, "error sending subscription payment issue email", 0, map[string]interface{}{"email": subsInfo.Email, "user_id": userId}) + continue + } + + // batch update email sent ts later + for _, sub := range subsInfo.PremiumSubs { + premiumAddonIds = append(premiumAddonIds, sub.Id) + } + for _, sub := range subsInfo.AddonSubs { + premiumAddonIds = append(premiumAddonIds, sub.Id) + } + for _, sub := range subsInfo.ApiSubs { + apiIds = append(apiIds, sub.Id) + } + } + + // update grace Period warning sent timestamp + if err := updateGraceTs(premiumAddonIds, "users_app_subscriptions"); err != nil { + log.Error(err, "error updating premium/addon grace period warning email timestamps", 0) + } + if err := updateGraceTs(apiIds, "users_stripe_subscriptions"); err != nil { + log.Error(err, "error updating api grace period warning email timestamps", 0) + } + + services.ReportStatus("subscription_end_reminder", "Running", nil) + + log.InfoWithFields(log.Fields{"mails sent": mailsSent, "duration": time.Since(start)}, "sending subscription payment issue warnings completed") + time.Sleep(time.Hour * 4) + } +} + +func getPendingGracePeriodSubscriptionsByUser() (map[uint64]*ExpiredSubsInfo, error) { + var err error + type expiredSubscriptionResult struct { + Email string `db:"email"` + UserId uint64 `db:"user_id"` + SubscriptionId string `db:"subscription_id"` + Store t.ProductStore `db:"store"` + ProductId string `db:"product_id"` + End time.Time `db:"end"` + } + baseDs := goqu.Dialect("postgres"). + Select( + goqu.I("u.email"), + goqu.I("u.id").As("user_id"), + ). + From(goqu.T("users").As("u")). + Where( + goqu.I("active").Eq(true), + goqu.Or( + goqu.L("payment_issues_mail_ts").IsNull(), + goqu.L("payment_issues_mail_ts").Lt(time.Now().Add(-reminderFrequency)), + ), + ) + + execQuery := func(ds *goqu.SelectDataset, res *[]expiredSubscriptionResult) error { + query, args, err := ds.Prepared(true).ToSQL() + if err != nil { + return err + } + err = db.FrontendReaderDB.Select(res, query, args...) + return err + } + + wg := errgroup.Group{} + var premiumResults, apiResults []expiredSubscriptionResult + + // get app (also called premium or mobile) and add-on subscriptions, stored in users_app_subscriptions + wg.Go(func() error { + expiredAppSubscriptionssDs := baseDs. + SelectAppend( + goqu.L("uas.id").As("subscription_id"), + goqu.I("uas.product_id"), + goqu.L("uas.expires_at").As("end"), + goqu.L("store"), + ). + LeftJoin( + goqu.T("users_app_subscriptions").As("uas"), + goqu.On( + goqu.I("uas.user_id").Eq(goqu.I("u.id")), + ), + ). + Where( + goqu.L("uas.expires_at").Lt(time.Now()), + goqu.L("EXTRACT(epoch FROM uas.expires_at)").Gt(0), + ) + return execQuery(expiredAppSubscriptionssDs, &premiumResults) + }) + + // get api subscriptions, stored in users_stripe_subscriptions + wg.Go(func() error { + expiredApiSubscriptionssDs := baseDs. + SelectAppend( + goqu.L("uss.subscription_id"), + goqu.I("uss.price_id").As("product_id"), + goqu.L("to_timestamp((uss.payload->>'current_period_end')::bigint)").As("end"), + goqu.L("'stripe'").As("store"), + ). + LeftJoin( + goqu.T("users_stripe_subscriptions").As("uss"), + goqu.On( + goqu.I("u.stripe_customer_id").Eq(goqu.I("uss.customer_id")), + goqu.I("uss.purchase_group").Eq(utils.GROUP_API), + ), + ). + Where( + goqu.L("to_timestamp((uss.payload->>'current_period_end')::bigint)").Lt(time.Now()), + goqu.L("(uss.payload->>'current_period_end')::bigint").Gt(0), + ) + err = execQuery(expiredApiSubscriptionssDs, &apiResults) + if err != nil { + return err + } + for i, res := range apiResults { + productId := utils.PriceIdToProductId(res.ProductId) + if productId == "" { + log.Error(nil, "unmapped stripe subscription price id", 0, map[string]interface{}{"price_id": res.ProductId}) + } + apiResults[i].ProductId = productId + } + return err + }) + + err = wg.Wait() + if err != nil { + return nil, err + } + + subsByUser := make(map[uint64]*ExpiredSubsInfo) + for _, subResult := range append(premiumResults, apiResults...) { + switch subResult.Store { + case t.ProductStoreStripe, t.ProductStoreIosAppstore, t.ProductStoreAndroidPlaystore: + default: + // ethpool and custom are not supported + log.Error(nil, "unsupported subscription store", 0, map[string]interface{}{"store": subResult.Store}) + continue + } + productName := utils.EffectiveProductName(subResult.ProductId) + if productName == "" { + log.Error(nil, "unmapped subscription product id", 0, map[string]interface{}{"product_id": subResult.ProductId}) + continue + } + + var subsInfo *ExpiredSubsInfo + if _, exists := subsByUser[subResult.UserId]; !exists { + subsInfo = &ExpiredSubsInfo{} + } else { + subsInfo = subsByUser[subResult.UserId] + } + + subsInfo.Email = subResult.Email + sub := GraceSubscription{ + ProductName: productName, + End: subResult.End, + Id: subResult.SubscriptionId, + Store: subResult.Store, + } + + switch utils.GetPurchaseGroup(subResult.ProductId) { + case utils.GROUP_API: + subsInfo.ApiSubs = append(subsInfo.ApiSubs, sub) + case utils.GROUP_MOBILE: + subsInfo.PremiumSubs = append(subsInfo.PremiumSubs, sub) + case utils.GROUP_ADDON: + subsInfo.AddonSubs = append(subsInfo.AddonSubs, sub) + default: + log.Error(nil, "unmapped subscription product group", 0, map[string]interface{}{"product_id": subResult.ProductId}) + continue + } + subsByUser[subResult.UserId] = subsInfo + } + return subsByUser, nil +} + +func formatEmail(subsInfo *ExpiredSubsInfo) template.HTML { + var content template.HTML + + content += template.HTML("We had issues processing your subscription payments. You are currently granted a grace period so you can renew your subscription(s). Failure to do so in time could result in your validator dashboards getting archived or permanently deleted!


The following products are affected:
") + + formatSubs := func(subs []GraceSubscription, category string) { + //nolint:gosec // enum string + tempContent := template.HTML(fmt.Sprintf("%s Subscriptions:
", category)) + for _, sub := range subs { + var store string + switch sub.Store { + case t.ProductStoreStripe: + store = fmt.Sprintf(`Manage`, utils.Config.Frontend.SiteDomain) + case t.ProductStoreAndroidPlaystore: + store = "check Google Play Store" + case t.ProductStoreIosAppstore: + store = "check Apple App Store" + } + //nolint:gosec + tempContent += template.HTML(fmt.Sprintf(" %s (%s, expires on %s)
", sub.ProductName, store, sub.End.Add(gracePeriod).Format("Mon Jan 2 2006"))) + } + content += "
" + } + formatSubs(subsInfo.PremiumSubs, "Premium") + formatSubs(subsInfo.AddonSubs, "Premium Add-On") + formatSubs(subsInfo.ApiSubs, "API") + return content +} + +func updateGraceTs(ids []string, table string) error { + if len(ids) == 0 { + return nil + } + idColumn := goqu.I("id") + if table == "users_stripe_subscriptions" { + idColumn = goqu.I("subscription_id") + } + ds := goqu.Dialect("postgres"). + Update(table). + Set(goqu.Record{"payment_issues_mail_ts": time.Now()}). + Where(idColumn.In(ids)) + + query, args, err := ds.Prepared(true).ToSQL() + if err != nil { + return err + } + _, err = db.FrontendWriterDB.Exec(query, args...) + return err +}