Skip to content

Commit

Permalink
feat: payment issue notification service
Browse files Browse the repository at this point in the history
  • Loading branch information
remoterami committed Jan 16, 2025
1 parent 493895f commit 8d1258d
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 2 deletions.
1 change: 1 addition & 0 deletions backend/cmd/user_service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,5 @@ func Init() {
log.Infof("starting user service")
go userservice.StripeEmailUpdater()
go userservice.CheckMobileSubscriptions()
go userservice.SubscriptionEndReminder()
}
Original file line number Diff line number Diff line change
@@ -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
37 changes: 35 additions & 2 deletions backend/pkg/commons/utils/products.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand All @@ -73,15 +94,27 @@ 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 ""
}
}

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:
Expand Down
207 changes: 207 additions & 0 deletions backend/pkg/userservice/subscription_end_reminder.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 8d1258d

Please sign in to comment.