|
| 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