Skip to content

Commit 24159f1

Browse files
AchoArnoldCopilot
andcommitted
feat: add entitlement service to limit send schedules for free users
- Create EntitlementService with configurable entity limits per plan - Add ENTITLEMENT_ENABLED env var (defaults to false for self-hosted) - Free users limited to 1 send schedule, paid users unlimited - Add CountByUser to send schedule repository for efficient counting - Return 402 Payment Required when limit exceeded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1e53c55 commit 24159f1

7 files changed

Lines changed: 174 additions & 8 deletions

File tree

api/.env.docker

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ GCP_PROJECT_ID=httpsms-docker
55

66
USE_HTTP_LOGGER=true
77

8+
# Set to "true" to enable feature entitlement checks (limits for free users).
9+
# Defaults to "false" for self-hosted deployments (no limits).
10+
ENTITLEMENT_ENABLED=false
11+
812
EVENTS_QUEUE_TYPE=emulator
913
EVENTS_QUEUE_NAME=events-local
1014
EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events

api/pkg/di/container.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler
788788
container.Tracer(),
789789
container.SendScheduleHandlerValidator(),
790790
container.SendScheduleService(),
791+
container.EntitlementService(),
791792
)
792793
}
793794

@@ -801,6 +802,17 @@ func (container *Container) BillingUsageRepository() (repository repositories.Bi
801802
)
802803
}
803804

805+
// EntitlementService creates a new instance of services.EntitlementService
806+
func (container *Container) EntitlementService() *services.EntitlementService {
807+
container.logger.Debug("creating services.EntitlementService")
808+
return services.NewEntitlementService(
809+
container.Logger(),
810+
container.Tracer(),
811+
os.Getenv("ENTITLEMENT_ENABLED") == "true",
812+
container.UserRepository(),
813+
)
814+
}
815+
804816
// DiscordRepository creates a new instance of repositories.DiscordRepository
805817
func (container *Container) DiscordRepository() (repository repositories.DiscordRepository) {
806818
container.logger.Debug("creating GORM repositories.DiscordRepository")

api/pkg/handlers/send_schedule_handler.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import (
1717
// SendScheduleHandler handles HTTP requests for message send schedules.
1818
type SendScheduleHandler struct {
1919
handler
20-
logger telemetry.Logger
21-
tracer telemetry.Tracer
22-
validator *validators.SendScheduleHandlerValidator
23-
service *services.SendScheduleService
20+
logger telemetry.Logger
21+
tracer telemetry.Tracer
22+
validator *validators.SendScheduleHandlerValidator
23+
service *services.SendScheduleService
24+
entitlementService *services.EntitlementService
2425
}
2526

2627
// NewSendScheduleHandler creates a new SendScheduleHandler.
@@ -29,12 +30,14 @@ func NewSendScheduleHandler(
2930
tracer telemetry.Tracer,
3031
validator *validators.SendScheduleHandlerValidator,
3132
service *services.SendScheduleService,
33+
entitlementService *services.EntitlementService,
3234
) *SendScheduleHandler {
3335
return &SendScheduleHandler{
34-
logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})),
35-
tracer: tracer,
36-
validator: validator,
37-
service: service,
36+
logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})),
37+
tracer: tracer,
38+
validator: validator,
39+
service: service,
40+
entitlementService: entitlementService,
3841
}
3942
}
4043

@@ -82,13 +85,31 @@ func (h *SendScheduleHandler) Index(c *fiber.Ctx) error {
8285
// @Success 201 {object} responses.SendScheduleResponse
8386
// @Failure 400 {object} responses.BadRequest
8487
// @Failure 401 {object} responses.Unauthorized
88+
// @Failure 402 {object} responses.BadRequest
8589
// @Failure 422 {object} responses.UnprocessableEntity
8690
// @Failure 500 {object} responses.InternalServerError
8791
// @Router /send-schedules [post]
8892
func (h *SendScheduleHandler) Store(c *fiber.Ctx) error {
8993
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
9094
defer span.End()
9195

96+
userID := h.userIDFomContext(c)
97+
98+
count, err := h.service.CountByUser(ctx, userID)
99+
if err != nil {
100+
ctxLogger.Error(stacktrace.Propagate(err, "cannot count send schedules for entitlement check"))
101+
return h.responseInternalServerError(c)
102+
}
103+
104+
result, err := h.entitlementService.Check(ctx, userID, "MessageSendSchedule", count)
105+
if err != nil {
106+
ctxLogger.Error(stacktrace.Propagate(err, "cannot check entitlement for send schedules"))
107+
return h.responseInternalServerError(c)
108+
}
109+
if !result.Allowed {
110+
return h.responsePaymentRequired(c, result.Message)
111+
}
112+
92113
var request requests.SendScheduleStore
93114
if err := c.BodyParser(&request); err != nil {
94115
return h.responseBadRequest(c, err)

api/pkg/repositories/gorm_send_schedule_repository.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,25 @@ func (r *gormSendScheduleRepository) DeleteAllForUser(
166166

167167
return nil
168168
}
169+
170+
// CountByUser returns the number of schedules owned by a user.
171+
func (r *gormSendScheduleRepository) CountByUser(
172+
ctx context.Context,
173+
userID entities.UserID,
174+
) (int, error) {
175+
ctx, span := r.tracer.Start(ctx)
176+
defer span.End()
177+
178+
var count int64
179+
if err := r.db.WithContext(ctx).
180+
Model(&entities.MessageSendSchedule{}).
181+
Where("user_id = ?", userID).
182+
Count(&count).Error; err != nil {
183+
return 0, r.tracer.WrapErrorSpan(
184+
span,
185+
stacktrace.Propagate(err, "cannot count send schedules for user [%s]", userID),
186+
)
187+
}
188+
189+
return int(count), nil
190+
}

api/pkg/repositories/send_schedule_repository.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,7 @@ type SendScheduleRepository interface {
2626

2727
// DeleteAllForUser removes all message send schedules owned by a user.
2828
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
29+
30+
// CountByUser returns the number of schedules owned by a user.
31+
CountByUser(ctx context.Context, userID entities.UserID) (int, error)
2932
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package services
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/NdoleStudio/httpsms/pkg/entities"
8+
"github.com/NdoleStudio/httpsms/pkg/repositories"
9+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
10+
"github.com/palantir/stacktrace"
11+
)
12+
13+
// entityLimits maps entity name → subscription plan → max count.
14+
// A limit of 0 means unlimited. If a plan is not listed, it defaults to unlimited (0).
15+
var entityLimits = map[string]map[entities.SubscriptionName]int{
16+
"MessageSendSchedule": {
17+
entities.SubscriptionNameFree: 1,
18+
},
19+
}
20+
21+
// EntitlementCheckResult holds the outcome of an entitlement check.
22+
type EntitlementCheckResult struct {
23+
Allowed bool
24+
Message string
25+
}
26+
27+
// EntitlementService checks whether a user can create more of a given entity
28+
// based on their subscription plan.
29+
type EntitlementService struct {
30+
service
31+
logger telemetry.Logger
32+
tracer telemetry.Tracer
33+
enabled bool
34+
userRepository repositories.UserRepository
35+
}
36+
37+
// NewEntitlementService creates a new EntitlementService.
38+
// The enabled flag should come from the ENTITLEMENT_ENABLED environment variable.
39+
func NewEntitlementService(
40+
logger telemetry.Logger,
41+
tracer telemetry.Tracer,
42+
enabled bool,
43+
userRepository repositories.UserRepository,
44+
) *EntitlementService {
45+
return &EntitlementService{
46+
logger: logger.WithService(fmt.Sprintf("%T", &EntitlementService{})),
47+
tracer: tracer,
48+
enabled: enabled,
49+
userRepository: userRepository,
50+
}
51+
}
52+
53+
// Check verifies if the user can create another instance of the given entity.
54+
func (service *EntitlementService) Check(
55+
ctx context.Context,
56+
userID entities.UserID,
57+
entityName string,
58+
currentCount int,
59+
) (*EntitlementCheckResult, error) {
60+
ctx, span := service.tracer.Start(ctx)
61+
defer span.End()
62+
63+
if !service.enabled {
64+
return &EntitlementCheckResult{Allowed: true}, nil
65+
}
66+
67+
limits, exists := entityLimits[entityName]
68+
if !exists {
69+
return &EntitlementCheckResult{Allowed: true}, nil
70+
}
71+
72+
user, err := service.userRepository.Load(ctx, userID)
73+
if err != nil {
74+
return nil, service.tracer.WrapErrorSpan(
75+
span,
76+
stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] for entitlement check", userID)),
77+
)
78+
}
79+
80+
limit, hasLimit := limits[user.SubscriptionName]
81+
if !hasLimit || limit == 0 {
82+
return &EntitlementCheckResult{Allowed: true}, nil
83+
}
84+
85+
if currentCount >= limit {
86+
return &EntitlementCheckResult{
87+
Allowed: false,
88+
Message: fmt.Sprintf(
89+
"Upgrade to a paid plan to create more than %d send schedule. Visit https://httpsms.com/pricing for details.",
90+
limit,
91+
),
92+
}, nil
93+
}
94+
95+
return &EntitlementCheckResult{Allowed: true}, nil
96+
}

api/pkg/services/send_schedule_service.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ func (service *SendScheduleService) Index(
5151
return service.repository.Index(ctx, userID)
5252
}
5353

54+
// CountByUser returns the number of schedules owned by a user.
55+
func (service *SendScheduleService) CountByUser(
56+
ctx context.Context,
57+
userID entities.UserID,
58+
) (int, error) {
59+
return service.repository.CountByUser(ctx, userID)
60+
}
61+
5462
// Load returns a single message send schedule for a user.
5563
func (service *SendScheduleService) Load(
5664
ctx context.Context,

0 commit comments

Comments
 (0)