|
| 1 | +package userservice |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "html/template" |
| 6 | + "time" |
| 7 | + |
| 8 | + "github.com/doug-martin/goqu/v9" |
| 9 | + t "github.com/gobitfly/beaconchain/pkg/api/types" |
| 10 | + "github.com/gobitfly/beaconchain/pkg/commons/db" |
| 11 | + "github.com/gobitfly/beaconchain/pkg/commons/log" |
| 12 | + "github.com/gobitfly/beaconchain/pkg/commons/mail" |
| 13 | + "github.com/gobitfly/beaconchain/pkg/commons/services" |
| 14 | + "github.com/gobitfly/beaconchain/pkg/commons/types" |
| 15 | + "github.com/gobitfly/beaconchain/pkg/commons/utils" |
| 16 | + "golang.org/x/sync/errgroup" |
| 17 | +) |
| 18 | + |
| 19 | +type GraceSubscription struct { |
| 20 | + Id string |
| 21 | + ProductName string |
| 22 | + End time.Time |
| 23 | + Store t.ProductStore |
| 24 | +} |
| 25 | + |
| 26 | +type ExpiredSubsInfo struct { |
| 27 | + Email string |
| 28 | + PremiumSubs []GraceSubscription |
| 29 | + AddonSubs []GraceSubscription |
| 30 | + ApiSubs []GraceSubscription |
| 31 | +} |
| 32 | + |
| 33 | +const reminderFrequency = utils.Week |
| 34 | +const gracePeriod = utils.Week * 2 |
| 35 | + |
| 36 | +func SubscriptionEndReminder() { |
| 37 | + for { |
| 38 | + start := time.Now() |
| 39 | + |
| 40 | + // get all subscriptions running on grace period which haven't been warned recently |
| 41 | + gracePeriodSubscriptions, err := getPendingGracePeriodSubscriptionsByUser() |
| 42 | + if err != nil { |
| 43 | + log.Error(err, "error getting subscriptions in grace period", 0) |
| 44 | + time.Sleep(time.Second * 10) |
| 45 | + continue |
| 46 | + } |
| 47 | + |
| 48 | + mailsSent := 0 |
| 49 | + var premiumAddonIds, apiIds []string |
| 50 | + for userId, subsInfo := range gracePeriodSubscriptions { |
| 51 | + // send email |
| 52 | + // not checking/increasing daily rate limit here |
| 53 | + email := types.Email{ |
| 54 | + Body: formatEmail(subsInfo), |
| 55 | + Title: "Subscription payment issue", |
| 56 | + SubscriptionManageURL: "beaconcha.in", |
| 57 | + } |
| 58 | + if err = mail.SendHTMLMail(subsInfo.Email, "beaconcha.in - Subscription payment issue", email, []types.EmailAttachment{}); err != nil { |
| 59 | + log.Error(err, "error sending subscription payment issue email", 0, map[string]interface{}{"email": subsInfo.Email, "user_id": userId}) |
| 60 | + continue |
| 61 | + } |
| 62 | + |
| 63 | + // batch update email sent ts later |
| 64 | + for _, sub := range subsInfo.PremiumSubs { |
| 65 | + premiumAddonIds = append(premiumAddonIds, sub.Id) |
| 66 | + } |
| 67 | + for _, sub := range subsInfo.AddonSubs { |
| 68 | + premiumAddonIds = append(premiumAddonIds, sub.Id) |
| 69 | + } |
| 70 | + for _, sub := range subsInfo.ApiSubs { |
| 71 | + apiIds = append(apiIds, sub.Id) |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + // update grace Period warning sent timestamp |
| 76 | + if err := updateGraceTs(premiumAddonIds, "users_app_subscriptions"); err != nil { |
| 77 | + log.Error(err, "error updating premium/addon grace period warning email timestamps", 0) |
| 78 | + } |
| 79 | + if err := updateGraceTs(apiIds, "users_stripe_subscriptions"); err != nil { |
| 80 | + log.Error(err, "error updating api grace period warning email timestamps", 0) |
| 81 | + } |
| 82 | + |
| 83 | + services.ReportStatus("subscription_end_reminder", "Running", nil) |
| 84 | + |
| 85 | + log.InfoWithFields(log.Fields{"mails sent": mailsSent, "duration": time.Since(start)}, "sending subscription payment issue warnings completed") |
| 86 | + time.Sleep(time.Hour * 4) |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +func getPendingGracePeriodSubscriptionsByUser() (map[uint64]*ExpiredSubsInfo, error) { |
| 91 | + var err error |
| 92 | + type expiredSubscriptionResult struct { |
| 93 | + Email string `db:"email"` |
| 94 | + UserId uint64 `db:"user_id"` |
| 95 | + SubscriptionId string `db:"subscription_id"` |
| 96 | + Store t.ProductStore `db:"store"` |
| 97 | + ProductId string `db:"product_id"` |
| 98 | + End time.Time `db:"end"` |
| 99 | + } |
| 100 | + baseDs := goqu.Dialect("postgres"). |
| 101 | + Select( |
| 102 | + goqu.I("u.email"), |
| 103 | + goqu.I("u.id").As("user_id"), |
| 104 | + ). |
| 105 | + From(goqu.T("users").As("u")). |
| 106 | + Where( |
| 107 | + goqu.I("active").Eq(true), |
| 108 | + goqu.Or( |
| 109 | + goqu.L("payment_issues_mail_ts").IsNull(), |
| 110 | + goqu.L("payment_issues_mail_ts").Lt(time.Now().Add(-reminderFrequency)), |
| 111 | + ), |
| 112 | + ) |
| 113 | + |
| 114 | + execQuery := func(ds *goqu.SelectDataset, res *[]expiredSubscriptionResult) error { |
| 115 | + query, args, err := ds.Prepared(true).ToSQL() |
| 116 | + if err != nil { |
| 117 | + return err |
| 118 | + } |
| 119 | + err = db.FrontendReaderDB.Select(res, query, args...) |
| 120 | + return err |
| 121 | + } |
| 122 | + |
| 123 | + wg := errgroup.Group{} |
| 124 | + var premiumResults, apiResults []expiredSubscriptionResult |
| 125 | + |
| 126 | + // get app (also called premium or mobile) and add-on subscriptions, stored in users_app_subscriptions |
| 127 | + wg.Go(func() error { |
| 128 | + expiredAppSubscriptionssDs := baseDs. |
| 129 | + SelectAppend( |
| 130 | + goqu.L("uas.id").As("subscription_id"), |
| 131 | + goqu.I("uas.product_id"), |
| 132 | + goqu.L("uas.expires_at").As("end"), |
| 133 | + goqu.L("store"), |
| 134 | + ). |
| 135 | + LeftJoin( |
| 136 | + goqu.T("users_app_subscriptions").As("uas"), |
| 137 | + goqu.On( |
| 138 | + goqu.I("uas.user_id").Eq(goqu.I("u.id")), |
| 139 | + ), |
| 140 | + ). |
| 141 | + Where( |
| 142 | + goqu.L("uas.expires_at").Lt(time.Now()), |
| 143 | + goqu.L("EXTRACT(epoch FROM uas.expires_at)").Gt(0), |
| 144 | + ) |
| 145 | + return execQuery(expiredAppSubscriptionssDs, &premiumResults) |
| 146 | + }) |
| 147 | + |
| 148 | + // get api subscriptions, stored in users_stripe_subscriptions |
| 149 | + wg.Go(func() error { |
| 150 | + expiredApiSubscriptionssDs := baseDs. |
| 151 | + SelectAppend( |
| 152 | + goqu.L("uss.subscription_id"), |
| 153 | + goqu.I("uss.price_id").As("product_id"), |
| 154 | + goqu.L("to_timestamp((uss.payload->>'current_period_end')::bigint)").As("end"), |
| 155 | + goqu.L("'stripe'").As("store"), |
| 156 | + ). |
| 157 | + LeftJoin( |
| 158 | + goqu.T("users_stripe_subscriptions").As("uss"), |
| 159 | + goqu.On( |
| 160 | + goqu.I("u.stripe_customer_id").Eq(goqu.I("uss.customer_id")), |
| 161 | + goqu.I("uss.purchase_group").Eq(utils.GROUP_API), |
| 162 | + ), |
| 163 | + ). |
| 164 | + Where( |
| 165 | + goqu.L("to_timestamp((uss.payload->>'current_period_end')::bigint)").Lt(time.Now()), |
| 166 | + goqu.L("(uss.payload->>'current_period_end')::bigint").Gt(0), |
| 167 | + ) |
| 168 | + err = execQuery(expiredApiSubscriptionssDs, &apiResults) |
| 169 | + if err != nil { |
| 170 | + return err |
| 171 | + } |
| 172 | + for i, res := range apiResults { |
| 173 | + productId := utils.PriceIdToProductId(res.ProductId) |
| 174 | + if productId == "" { |
| 175 | + log.Error(nil, "unmapped stripe subscription price id", 0, map[string]interface{}{"price_id": res.ProductId}) |
| 176 | + } |
| 177 | + apiResults[i].ProductId = productId |
| 178 | + } |
| 179 | + return err |
| 180 | + }) |
| 181 | + |
| 182 | + err = wg.Wait() |
| 183 | + if err != nil { |
| 184 | + return nil, err |
| 185 | + } |
| 186 | + |
| 187 | + subsByUser := make(map[uint64]*ExpiredSubsInfo) |
| 188 | + for _, subResult := range append(premiumResults, apiResults...) { |
| 189 | + switch subResult.Store { |
| 190 | + case t.ProductStoreStripe, t.ProductStoreIosAppstore, t.ProductStoreAndroidPlaystore: |
| 191 | + default: |
| 192 | + // ethpool and custom are not supported |
| 193 | + log.Error(nil, "unsupported subscription store", 0, map[string]interface{}{"store": subResult.Store}) |
| 194 | + continue |
| 195 | + } |
| 196 | + productName := utils.EffectiveProductName(subResult.ProductId) |
| 197 | + if productName == "" { |
| 198 | + log.Error(nil, "unmapped subscription product id", 0, map[string]interface{}{"product_id": subResult.ProductId}) |
| 199 | + continue |
| 200 | + } |
| 201 | + |
| 202 | + var subsInfo *ExpiredSubsInfo |
| 203 | + if _, exists := subsByUser[subResult.UserId]; !exists { |
| 204 | + subsInfo = &ExpiredSubsInfo{} |
| 205 | + } else { |
| 206 | + subsInfo = subsByUser[subResult.UserId] |
| 207 | + } |
| 208 | + |
| 209 | + subsInfo.Email = subResult.Email |
| 210 | + sub := GraceSubscription{ |
| 211 | + ProductName: productName, |
| 212 | + End: subResult.End, |
| 213 | + Id: subResult.SubscriptionId, |
| 214 | + Store: subResult.Store, |
| 215 | + } |
| 216 | + |
| 217 | + switch utils.GetPurchaseGroup(subResult.ProductId) { |
| 218 | + case utils.GROUP_API: |
| 219 | + subsInfo.ApiSubs = append(subsInfo.ApiSubs, sub) |
| 220 | + case utils.GROUP_MOBILE: |
| 221 | + subsInfo.PremiumSubs = append(subsInfo.PremiumSubs, sub) |
| 222 | + case utils.GROUP_ADDON: |
| 223 | + subsInfo.AddonSubs = append(subsInfo.AddonSubs, sub) |
| 224 | + default: |
| 225 | + log.Error(nil, "unmapped subscription product group", 0, map[string]interface{}{"product_id": subResult.ProductId}) |
| 226 | + continue |
| 227 | + } |
| 228 | + subsByUser[subResult.UserId] = subsInfo |
| 229 | + } |
| 230 | + return subsByUser, nil |
| 231 | +} |
| 232 | + |
| 233 | +func formatEmail(subsInfo *ExpiredSubsInfo) template.HTML { |
| 234 | + var content template.HTML |
| 235 | + |
| 236 | + 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!<br><br><br>The following products are affected:<br>") |
| 237 | + |
| 238 | + formatSubs := func(subs []GraceSubscription, category string) { |
| 239 | + //nolint:gosec // enum string |
| 240 | + tempContent := template.HTML(fmt.Sprintf("<u>%s Subscriptions:</u><br>", category)) |
| 241 | + for _, sub := range subs { |
| 242 | + var store string |
| 243 | + switch sub.Store { |
| 244 | + case t.ProductStoreStripe: |
| 245 | + store = fmt.Sprintf(`<a href="%s/pricing">Manage</a>`, utils.Config.Frontend.SiteDomain) |
| 246 | + case t.ProductStoreAndroidPlaystore: |
| 247 | + store = "check Google Play Store" |
| 248 | + case t.ProductStoreIosAppstore: |
| 249 | + store = "check Apple App Store" |
| 250 | + } |
| 251 | + //nolint:gosec |
| 252 | + tempContent += template.HTML(fmt.Sprintf(" %s (%s, expires on %s)<br>", sub.ProductName, store, sub.End.Add(gracePeriod).Format("Mon Jan 2 2006"))) |
| 253 | + } |
| 254 | + content += "<br>" |
| 255 | + } |
| 256 | + formatSubs(subsInfo.PremiumSubs, "Premium") |
| 257 | + formatSubs(subsInfo.AddonSubs, "Premium Add-On") |
| 258 | + formatSubs(subsInfo.ApiSubs, "API") |
| 259 | + return content |
| 260 | +} |
| 261 | + |
| 262 | +func updateGraceTs(ids []string, table string) error { |
| 263 | + if len(ids) == 0 { |
| 264 | + return nil |
| 265 | + } |
| 266 | + idColumn := goqu.I("id") |
| 267 | + if table == "users_stripe_subscriptions" { |
| 268 | + idColumn = goqu.I("subscription_id") |
| 269 | + } |
| 270 | + ds := goqu.Dialect("postgres"). |
| 271 | + Update(table). |
| 272 | + Set(goqu.Record{"payment_issues_mail_ts": time.Now()}). |
| 273 | + Where(idColumn.In(ids)) |
| 274 | + |
| 275 | + query, args, err := ds.Prepared(true).ToSQL() |
| 276 | + if err != nil { |
| 277 | + return err |
| 278 | + } |
| 279 | + _, err = db.FrontendWriterDB.Exec(query, args...) |
| 280 | + return err |
| 281 | +} |
0 commit comments