Skip to content

Commit b6e8d66

Browse files
committed
feat: payment issue notification service
1 parent 493895f commit b6e8d66

5 files changed

Lines changed: 337 additions & 3 deletions

File tree

backend/cmd/user_service/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,5 @@ func Init() {
103103
log.Infof("starting user service")
104104
go userservice.StripeEmailUpdater()
105105
go userservice.CheckMobileSubscriptions()
106+
go userservice.SubscriptionEndReminder()
106107
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
ALTER TABLE users_app_subscriptions
4+
ADD COLUMN IF NOT EXISTS payment_issues_mail_ts TIMESTAMP WITHOUT TIME ZONE;
5+
-- +goose StatementEnd
6+
-- +goose StatementBegin
7+
ALTER TABLE users_stripe_subscriptions
8+
ADD COLUMN IF NOT EXISTS payment_issues_mail_ts TIMESTAMP WITHOUT TIME ZONE;
9+
-- +goose StatementEnd
10+
11+
-- +goose Down
12+
-- +goose StatementBegin
13+
ALTER TABLE users_app_subscriptions
14+
DROP COLUMN IF EXISTS payment_issues_mail_ts;
15+
-- +goose StatementEnd
16+
-- +goose StatementBegin
17+
ALTER TABLE users_stripe_subscriptions
18+
DROP COLUMN IF EXISTS payment_issues_mail_ts;
19+
-- +goose StatementEnd

backend/pkg/commons/mail/mail.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func SendHTMLMail(to, subject string, msg types.Email, attachment []types.EmailA
3434
if utils.Config.Frontend.Mail.SMTP.User != "" {
3535
headers := "MIME-version: 1.0;\nContent-Type: text/html;"
3636
body.Write([]byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n%s\r\n", to, subject, headers)))
37-
err = renderer.Execute(&body, MailTemplate{Mail: msg, Domain: utils.Config.Frontend.SiteDomain})
37+
err = renderer.ExecuteTemplate(&body, "layout", MailTemplate{Mail: msg, Domain: utils.Config.Frontend.SiteDomain})
3838
if err != nil {
3939
return fmt.Errorf("error rendering mail template: %w", err)
4040
}

backend/pkg/commons/utils/products.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const GROUP_MOBILE = "mobile"
55
const GROUP_ADDON = "addon"
66

77
var ProductsGroups = map[string]string{
8+
"sapphire": GROUP_API,
9+
"emerald": GROUP_API,
10+
"diamond": GROUP_API,
811
"plankton": GROUP_MOBILE,
912
"goldfish": GROUP_MOBILE,
1013
"whale": GROUP_MOBILE,
@@ -57,6 +60,24 @@ func EffectiveProductId(productId string) string {
5760
func EffectiveProductName(productId string) string {
5861
productId = EffectiveProductId(productId)
5962
switch productId {
63+
case "sapphire":
64+
return "Sapphire"
65+
case "emerald":
66+
return "Emerald"
67+
case "diamond":
68+
return "Sapphire"
69+
case "iron":
70+
return "Iron"
71+
case "iron.yearly":
72+
return "Iron (yearly)"
73+
case "silver":
74+
return "Silver"
75+
case "silver.yearly":
76+
return "Silver (yearly)"
77+
case "gold":
78+
return "Gold"
79+
case "gold.yearly":
80+
return "Gold (yearly)"
6081
case "plankton":
6182
return "Plankton"
6283
case "goldfish":
@@ -73,15 +94,27 @@ func EffectiveProductName(productId string) string {
7394
return "Guppy (yearly)"
7495
case "dolphin.yearly":
7596
return "Dolphin (yearly)"
76-
case "orca.yearly":
77-
return "Orca (yearly)"
97+
case "vdb_addon_1k":
98+
return "1,000 dashboard validators Add-On"
99+
case "vdb_addon_1k.yearly":
100+
return "1,000 dashboard validators Add-On (yearly)"
101+
case "vdb_addon_10k":
102+
return "10,000 dashboard validators Add-On"
103+
case "vdb_addon_10k.yearly":
104+
return "10,000 dashboard validators Add-On) (yearly)"
78105
default:
79106
return ""
80107
}
81108
}
82109

83110
func PriceIdToProductId(priceId string) string {
84111
switch priceId {
112+
case Config.Frontend.Stripe.Sapphire:
113+
return "sapphire"
114+
case Config.Frontend.Stripe.Emerald:
115+
return "emerald"
116+
case Config.Frontend.Stripe.Diamond:
117+
return "diamond"
85118
case Config.Frontend.Stripe.Plankton:
86119
return "plankton"
87120
case Config.Frontend.Stripe.Goldfish:
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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("&emsp;%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

Comments
 (0)