Add maintenance job to delete trial accounts

This commit is contained in:
Taras Kushnir
2025-08-16 10:42:46 +03:00
parent 7751d0a186
commit 6c76c34df0
6 changed files with 151 additions and 3 deletions

View File

@@ -342,7 +342,7 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
Store: businessDB,
Templates: email.Templates(),
Sender: sender,
ChunkSize: 100,
ChunkSize: 50,
EmailFrom: cfg.Get(common.EmailFromKey),
ReplyToEmail: cfg.Get(common.ReplyToEmailKey),
CDNURL: mailer.CDNURL,
@@ -351,6 +351,12 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
jobs.AddLocked(24*time.Hour, &maintenance.CleanupUserNotificationsJob{
Store: businessDB,
})
jobs.AddLocked(24*time.Hour, &maintenance.CleanupExpiredTrialUsersJob{
Age: 30 * 24 * time.Hour,
BusinessDB: businessDB,
PlanService: planService,
ChunkSize: 20,
})
jobs.Run()
var localServer *http.Server

View File

@@ -260,10 +260,10 @@ func (impl *BusinessStoreImpl) SoftDeleteUser(ctx context.Context, userID int32)
}
if err := impl.querier.DeleteUserAPIKeys(ctx, Int(userID)); err != nil {
slog.ErrorContext(ctx, "Failed to soft-delete user API keys", "userID", userID, common.ErrAttr(err))
slog.ErrorContext(ctx, "Failed to delete user API keys", "userID", userID, common.ErrAttr(err))
return err
} else {
slog.DebugContext(ctx, "Disabled user API keys", "userID", userID)
slog.DebugContext(ctx, "Deleted user API keys", "userID", userID)
}
// TODO: Delete user API keys from cache
@@ -1755,3 +1755,28 @@ func (s *BusinessStoreImpl) DeletePendingUserNotification(ctx context.Context, u
return nil
}
func (s *BusinessStoreImpl) RetrieveUsersWithExpiredTrials(ctx context.Context, before time.Time, trialStatus string, maxUsers int32) ([]*dbgen.User, error) {
if s.querier == nil {
return nil, ErrMaintenance
}
users, err := s.querier.GetUsersWithExpiredTrials(ctx, &dbgen.GetUsersWithExpiredTrialsParams{
TrialEndsAt: Timestampz(before),
Status: trialStatus,
Limit: maxUsers,
})
if err != nil {
if err == pgx.ErrNoRows {
return []*dbgen.User{}, nil
}
slog.ErrorContext(ctx, "Failed to retrieve users with expired trials", "before", before, "status", trialStatus, common.ErrAttr(err))
return nil, err
}
slog.DebugContext(ctx, "Fetched users with expired trials", "count", len(users), "before", before, "status", trialStatus)
return users, nil
}

View File

@@ -61,6 +61,7 @@ type Querier interface {
GetUserBySubscriptionID(ctx context.Context, subscriptionID pgtype.Int4) (*User, error)
GetUserOrganizations(ctx context.Context, userID pgtype.Int4) ([]*GetUserOrganizationsRow, error)
GetUserPropertiesCount(ctx context.Context, orgOwnerID pgtype.Int4) (int64, error)
GetUsersWithExpiredTrials(ctx context.Context, arg *GetUsersWithExpiredTrialsParams) ([]*User, error)
GetUsersWithoutSubscription(ctx context.Context, dollar_1 []int32) ([]*User, error)
InsertLock(ctx context.Context, arg *InsertLockParams) (*Lock, error)
InviteUserToOrg(ctx context.Context, arg *InviteUserToOrgParams) (*OrganizationUser, error)

View File

@@ -147,6 +147,56 @@ func (q *Queries) GetUserBySubscriptionID(ctx context.Context, subscriptionID pg
return &i, err
}
const getUsersWithExpiredTrials = `-- name: GetUsersWithExpiredTrials :many
SELECT u.id, u.name, u.email, u.subscription_id, u.created_at, u.updated_at, u.deleted_at
FROM backend.users u
JOIN backend.subscriptions s ON u.subscription_id = s.id
WHERE
s.source = 'internal' AND
s.trial_ends_at IS NOT NULL AND
s.trial_ends_at < $1 AND
s.status = $2 AND
(s.external_customer_id IS NULL OR s.external_customer_id = '') AND
(s.external_subscription_id IS NULL OR s.external_subscription_id = '') AND
s.next_billed_at IS NULL AND
u.deleted_at IS NULL
LIMIT $3
`
type GetUsersWithExpiredTrialsParams struct {
TrialEndsAt pgtype.Timestamptz `db:"trial_ends_at" json:"trial_ends_at"`
Status string `db:"status" json:"status"`
Limit int32 `db:"limit" json:"limit"`
}
func (q *Queries) GetUsersWithExpiredTrials(ctx context.Context, arg *GetUsersWithExpiredTrialsParams) ([]*User, error) {
rows, err := q.db.Query(ctx, getUsersWithExpiredTrials, arg.TrialEndsAt, arg.Status, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.SubscriptionID,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUsersWithoutSubscription = `-- name: GetUsersWithoutSubscription :many
SELECT id, name, email, subscription_id, created_at, updated_at, deleted_at FROM backend.users where id = ANY($1::INT[]) AND (subscription_id IS NULL OR deleted_at IS NOT NULL)
`

View File

@@ -31,3 +31,18 @@ DELETE FROM backend.users WHERE id = ANY($1::INT[]);
-- name: GetUsersWithoutSubscription :many
SELECT * FROM backend.users where id = ANY($1::INT[]) AND (subscription_id IS NULL OR deleted_at IS NOT NULL);
-- name: GetUsersWithExpiredTrials :many
SELECT u.*
FROM backend.users u
JOIN backend.subscriptions s ON u.subscription_id = s.id
WHERE
s.source = 'internal' AND
s.trial_ends_at IS NOT NULL AND
s.trial_ends_at < $1 AND
s.status = $2 AND
(s.external_customer_id IS NULL OR s.external_customer_id = '') AND
(s.external_subscription_id IS NULL OR s.external_subscription_id = '') AND
s.next_billed_at IS NULL AND
u.deleted_at IS NULL
LIMIT $3;

View File

@@ -2,10 +2,13 @@ package maintenance
import (
"context"
"log/slog"
"time"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/billing"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
"github.com/jpillora/backoff"
)
const (
@@ -99,3 +102,51 @@ func (j *GarbageCollectDataJob) RunOnce(ctx context.Context) error {
return nil
}
type CleanupExpiredTrialUsersJob struct {
Age time.Duration
BusinessDB db.Implementor
PlanService billing.PlanService
ChunkSize int
}
func (j *CleanupExpiredTrialUsersJob) Interval() time.Duration {
return 12 * time.Hour
}
func (j *CleanupExpiredTrialUsersJob) Jitter() time.Duration {
return 6 * time.Hour
}
func (CleanupExpiredTrialUsersJob) Name() string {
return "cleanup_expired_trial_users_job"
}
func (j *CleanupExpiredTrialUsersJob) RunOnce(ctx context.Context) error {
expiredBefore := time.Now().Add(-j.Age)
users, err := j.BusinessDB.Impl().RetrieveUsersWithExpiredTrials(ctx, expiredBefore, j.PlanService.TrialStatus(), int32(j.ChunkSize))
if err != nil {
slog.ErrorContext(ctx, "Failed to retrieve users with expired trials", common.ErrAttr(err))
return err
}
b := &backoff.Backoff{
Min: 200 * time.Millisecond,
Max: 1 * time.Second,
Factor: 2,
Jitter: true,
}
for i, u := range users {
if (i > 0) && (err == nil) {
time.Sleep(b.Duration())
}
err = j.BusinessDB.Impl().SoftDeleteUser(ctx, u.ID)
if err != nil {
slog.ErrorContext(ctx, "Failed to soft-delete user", "userID", u.ID, common.ErrAttr(err))
}
}
return nil
}