Skip to content

Commit 1e53c55

Browse files
AchoArnoldCopilot
andcommitted
docs: add entitlement service design spec
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent acd63b0 commit 1e53c55

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Entitlement Service Design
2+
3+
## Problem
4+
5+
The send schedule feature (and future features) need usage limits based on the user's subscription plan. Free users should be limited to 1 send schedule; paid users get unlimited. The system must be:
6+
7+
- **Scalable**: Easy to add new entity limits without architectural changes
8+
- **Configurable**: Disabled by default for self-hosted deployments, enabled via env var for cloud
9+
- **Non-invasive**: Enforced at the handler layer, before business logic executes
10+
11+
## Approach
12+
13+
Create a dedicated `EntitlementService` in `pkg/services/` that:
14+
15+
1. Reads `ENTITLEMENT_ENABLED` from environment (defaults to `false`)
16+
2. Defines a code-based map of entity limits per subscription plan
17+
3. Exposes a single `Check()` method that handlers call before creating resources
18+
4. Returns 402 Payment Required when a free user exceeds their limit
19+
20+
## Configuration
21+
22+
### Environment Variable
23+
24+
```env
25+
# Set to "true" on cloud deployment; self-hosted defaults to false (no limits)
26+
ENTITLEMENT_ENABLED=false
27+
```
28+
29+
### Entity Limits (code-based)
30+
31+
```go
32+
// entityLimits maps entity name → subscription plan → max count
33+
// A limit of 0 means unlimited. If a plan is not listed, it defaults to unlimited.
34+
var entityLimits = map[string]map[entities.SubscriptionName]int{
35+
"MessageSendSchedule": {
36+
entities.SubscriptionNameFree: 1,
37+
},
38+
// Future: add more entities here
39+
// "Webhook": {
40+
// entities.SubscriptionNameFree: 3,
41+
// },
42+
}
43+
```
44+
45+
## Service Interface
46+
47+
```go
48+
// EntitlementService checks whether a user can create more of a given entity.
49+
type EntitlementService struct {
50+
logger telemetry.Logger
51+
tracer telemetry.Tracer
52+
enabled bool
53+
userRepository repositories.UserRepository
54+
}
55+
56+
// NewEntitlementService creates the service. `enabled` comes from ENTITLEMENT_ENABLED env var.
57+
func NewEntitlementService(
58+
logger telemetry.Logger,
59+
tracer telemetry.Tracer,
60+
enabled bool,
61+
userRepository repositories.UserRepository,
62+
) *EntitlementService
63+
64+
// CheckResult holds the outcome of an entitlement check.
65+
type CheckResult struct {
66+
Allowed bool
67+
Message string
68+
}
69+
70+
// Check verifies if the user can create another instance of the given entity.
71+
// - If entitlements are disabled (self-hosted), always returns Allowed: true.
72+
// - Loads the user's subscription plan.
73+
// - Looks up the limit for the entity + plan combination.
74+
// - Compares currentCount against the limit.
75+
func (s *EntitlementService) Check(
76+
ctx context.Context,
77+
userID entities.UserID,
78+
entityName string,
79+
currentCount int,
80+
) (*CheckResult, error)
81+
```
82+
83+
## Handler Integration
84+
85+
In `SendScheduleHandler.Store()`:
86+
87+
```go
88+
func (h *SendScheduleHandler) Store(c *fiber.Ctx) error {
89+
// 1. Validate request (existing logic)
90+
// 2. Get current count (efficient COUNT query)
91+
count, err := h.service.CountByUser(ctx, userID)
92+
if err != nil { ... }
93+
// 3. Check entitlement
94+
result, err := h.entitlementService.Check(ctx, userID, "MessageSendSchedule", count)
95+
if err != nil {
96+
return h.responseInternalServerError(c)
97+
}
98+
if !result.Allowed {
99+
return h.responsePaymentRequired(c, result.Message)
100+
}
101+
// 4. Proceed with creating schedule (existing logic)
102+
}
103+
```
104+
105+
## Repository Addition
106+
107+
Add to `SendScheduleRepository` interface and GORM implementation:
108+
109+
```go
110+
// CountByUser returns the number of schedules owned by a user.
111+
CountByUser(ctx context.Context, userID entities.UserID) (int, error)
112+
```
113+
114+
````
115+
116+
## Error Response
117+
118+
HTTP 402 Payment Required:
119+
120+
```json
121+
{
122+
"message": "Upgrade to a paid plan to create more than 1 send schedule. Visit https://httpsms.com/pricing for details.",
123+
"status": "payment_required"
124+
}
125+
````
126+
127+
## Files to Create/Modify
128+
129+
| Action | File | Change |
130+
| ------ | --------------------------------------------------- | ------------------------------------------------------------ |
131+
| Create | `pkg/services/entitlement_service.go` | New service with limits map, `Check()`, `CheckResult` |
132+
| Modify | `pkg/handlers/handler.go` | Add `responsePaymentRequired()` helper method |
133+
| Modify | `pkg/handlers/send_schedule_handler.go` | Inject `EntitlementService`, add check in `Store()` |
134+
| Modify | `pkg/di/container.go` | Wire `EntitlementService`, read env var, inject into handler |
135+
| Modify | `pkg/repositories/send_schedule_repository.go` | Add `CountByUser()` to interface |
136+
| Modify | `pkg/repositories/gorm_send_schedule_repository.go` | Implement `CountByUser()` with SQL COUNT |
137+
| Modify | `pkg/services/send_schedule_service.go` | Add `CountByUser()` pass-through method |
138+
| Modify | `.env.example` or `.env` | Add `ENTITLEMENT_ENABLED=false` |
139+
140+
## Concurrency & Race Conditions
141+
142+
The handler-level check (`count → check → create`) is not atomic. Two concurrent requests could both see `count=0` and both proceed. Mitigations:
143+
144+
1. **Repository count method**: Use `CountByUser(ctx, userID)` instead of loading all records (efficient SQL `SELECT COUNT(*)`).
145+
2. **Acceptable race window**: For a limit of 1, the worst case is 2 schedules created. This is acceptable because:
146+
- The window is extremely small (single user, same millisecond)
147+
- The consequence is minor (user has 2 schedules instead of 1)
148+
- A DB-level unique constraint is impractical here (limit is per-user count, not per-row uniqueness)
149+
3. **Future hardening**: If stricter enforcement is needed, add an advisory lock or transaction-based count+insert.
150+
151+
## Counting Semantics
152+
153+
All schedules owned by the user count toward the limit, regardless of `is_active` status. A user must delete a schedule to free up their quota.
154+
155+
## Error Handling When Enabled
156+
157+
- **Entitlements disabled** (`ENTITLEMENT_ENABLED=false`): Always returns `Allowed: true`, zero DB calls.
158+
- **Entitlements enabled, DB error loading user**: Return error (surfaces as 500). Do NOT fail-open — this is a monetized feature gate.
159+
- **Entitlements enabled, entity not in limits map**: Returns `Allowed: true` (entity has no restrictions).
160+
161+
## Design Decisions
162+
163+
1. **Handler-layer enforcement**: The handler gets the count and calls `Check()`. This keeps the entitlement service free of domain-specific repository dependencies.
164+
2. **Entity name as key**: Using the entity struct name (e.g., `"MessageSendSchedule"`) makes it self-documenting and matches the user's preference for entity-based naming.
165+
3. **Fail-open when disabled**: Self-hosted users never hit limits. The `enabled` flag short-circuits all checks.
166+
4. **Fail-closed on error when enabled**: If the user can't be loaded and entitlements are enabled, the request fails with 500.
167+
5. **Separate from BillingService**: BillingService handles SMS message counting/billing. EntitlementService handles feature-level access gating. Different concerns.
168+
6. **No caching**: User plan data is already fast to load. Caching can be added later if needed.
169+
170+
## Swagger & Handler Updates
171+
172+
- Add `@Failure 402 {object} responses.PaymentRequired` annotation to `Store` route
173+
- Add `responsePaymentRequired` helper to base handler struct
174+
- Update handler constructor to accept `*services.EntitlementService`
175+
176+
## Testing Strategy
177+
178+
- Unit test `EntitlementService.Check()` with:
179+
- Disabled mode → always allowed
180+
- Free user at limit → denied
181+
- Free user under limit → allowed
182+
- Paid user → always allowed
183+
- Unknown entity → allowed (no restrictions defined)
184+
- User load error when enabled → returns error
185+
- Handler test for `Store`:
186+
- Free user with 0 schedules → 201 Created
187+
- Free user with 1 schedule → 402 Payment Required
188+
- Paid user with N schedules → 201 Created

0 commit comments

Comments
 (0)