From 8d1258ddd53a9962030bae1c369ed9c6f6ffc208 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/utils/products.go | 37 +++- .../userservice/subscription_end_reminder.go | 207 ++++++++++++++++++ 4 files changed, 262 insertions(+), 2 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/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..bb7a0ce42 --- /dev/null +++ b/backend/pkg/userservice/subscription_end_reminder.go @@ -0,0 +1,207 @@ +package userservice + +import ( + "database/sql" + "fmt" + "time" + + "github.com/doug-martin/goqu/v9" + "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" +) + +type GraceSubscription struct { + Id uint64 + ProductId string + End time.Time +} + +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 have'nt 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 []uint64 + for userId, subsInfo := range gracePeriodSubscriptions { + // send email + content := formatEmail(subsInfo) + subject := fmt.Sprintf("%s: Subscription Payment Issues", utils.Config.Frontend.SiteDomain) + if mail.SendTextMail(subsInfo.Email, subject, content, []types.EmailAttachment{}) != nil { + log.Error(err, "error sending subscription payment issues email", 0, map[string]interface{}{"email": subsInfo.Email, "user_id": userId}) + continue + } + + // 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 + // not checking/increasing daily rate limit here + 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) { + type expiredSubscriptionResult struct { + Email string `db:"email"` + UserId uint64 `db:"user_id"` + SubscriptionId uint64 `db:"subscription_id"` + ProductId sql.NullString `db:"product_id"` + PriceId sql.NullString `db:"price_id"` + End time.Time `db:"end"` + } + expiredSubscriptionsDs := goqu.Dialect("postgres"). + Select( + goqu.I("u.email"), + goqu.I("u.id AS user_id"), + goqu.L("COALESCE(uas.id, uss.subscription_id) AS subscription_id"), // one should always be null + goqu.I("uas.product_id"), // premium (or app, mobile) subscriptions + goqu.I("uss.price_id"), // api subscriptions + goqu.L("COALESCE(to_timestamp(uas.expires_at, (uss.payload->>'current_period_end')::bigint)) AS end"), + ). + From(goqu.T("users")).As("u"). + InnerJoin( + goqu.T("users_app_subscriptions").As("uas"), + goqu.On( + goqu.I("uas.user_id").Eq(goqu.I("u.id")), + goqu.I("uas.active").Eq(true), + ), + ). + InnerJoin( + 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), + goqu.I("uss.active").Eq(true), + ), + ). + Where( + goqu.C("COALESCE(to_timestamp(uas.expires_at,(uss.payload->>'current_period_end')::bigint))").Lt(time.Now()), + goqu.C("COALESCE(to_timestamp(uas.expires_at,(uss.payload->>'current_period_end')::bigint))").Gt(0), + goqu.C("COALESCE(uas.payment_issues_mail_ts, uss.payment_issues_mail_ts)"). // one should always be null + Lt(time.Now().Add(-reminderFrequency)), + ) + + var expiredSubscriptionResults []expiredSubscriptionResult + query, args, err := expiredSubscriptionsDs.Prepared(true).ToSQL() + if err != nil { + return nil, err + } + err = db.FrontendReaderDB.Select(&expiredSubscriptionResults, query, args...) + if err != nil { + return nil, err + } + + subsByUser := make(map[uint64]*ExpiredSubsInfo) + for _, subResult := range expiredSubscriptionResults { + if !subResult.ProductId.Valid { + if !subResult.PriceId.Valid { + log.Error(nil, "subscription missing product_id and price_id", 0, map[string]interface{}{"user_id": subResult.UserId}) + continue + } + subResult.ProductId.String = utils.PriceIdToProductId(subResult.PriceId.String) + subResult.ProductId.Valid = true + } + if _, exists := subsByUser[subResult.UserId]; !exists { + subsByUser[subResult.UserId] = &ExpiredSubsInfo{} + } + + subsInfo := subsByUser[subResult.UserId] + subsInfo.Email = subResult.Email + sub := GraceSubscription{ProductId: subResult.ProductId.String, End: subResult.End, Id: subResult.SubscriptionId} + switch utils.GetPurchaseGroup(subResult.ProductId.String) { + 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) + } + } + return subsByUser, nil +} + +func formatEmail(subsInfo *ExpiredSubsInfo) string { + var content string + content += fmt.Sprintf("We had issues processing your subscription payments. You are currently granted a grace period so you can renew your subscription, please visit https://%[1]s/user/settings and check your payment method. Failure to do so in time could result in your validator dashboards getting archived or permanently deleted!\nThe following products are affected:\n\n", utils.Config.Frontend.SiteDomain) + + formatSubs := func(subs []GraceSubscription, category string) { + foundProducts := 0 + tempContent := fmt.Sprintf("%s Subscriptions:\n", category) + for _, sub := range subs { + productName := utils.EffectiveProductName(sub.ProductId) + if productName == "" { + log.Error(nil, "unmapped subscription product id", 0, map[string]interface{}{"product_id": sub.ProductId}) + continue + } + foundProducts++ + tempContent += fmt.Sprintf("\t%s (expires on %s)\n", productName, sub.End.Add(gracePeriod).Format("Mon Jan 2 2006")) + } + content += "\n" + if foundProducts > 0 { + content += tempContent + } + } + formatSubs(subsInfo.PremiumSubs, "Premium") + formatSubs(subsInfo.AddonSubs, "Premium Add-On") + formatSubs(subsInfo.ApiSubs, "API") + return "" +} + +func updateGraceTs(ids []uint64, table string) error { + 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 +}