Add Stripe billing, free trials, and cross-platform subscription guards

- Stripe integration: add StripeService with checkout sessions, customer
  portal, and webhook handling for subscription lifecycle events.
- Free trials: auto-start configurable trial on first subscription check,
  with admin-controllable duration and enable/disable toggle.
- Cross-platform guard: prevent duplicate subscriptions across iOS, Android,
  and Stripe by checking existing platform before allowing purchase.
- Subscription model: add Stripe fields (customer_id, subscription_id,
  price_id), trial fields (trial_start, trial_end, trial_used), and
  SubscriptionSource/IsTrialActive helpers.
- API: add trial and source fields to status response, update OpenAPI spec.
- Clean up stale migration and audit docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-05 11:36:14 -06:00
parent d5bb123cd0
commit 72db9050f8
35 changed files with 1555 additions and 1120 deletions

View File

@@ -1,417 +0,0 @@
# Gin to Echo Framework Migration Guide
This document outlines the migration of the MyCrib Go API from Gin to Echo v4 with direct go-playground/validator integration.
## Overview
| Aspect | Before | After |
|--------|--------|-------|
| Framework | Gin v1.10 | Echo v4.11 |
| Validation | Gin's binding wrapper | Direct go-playground/validator |
| Validation tags | `binding:"..."` | `validate:"..."` |
| Error format | Inconsistent | Structured field-level |
## Scope
- **56 files** requiring modification
- **110+ routes** across public and admin APIs
- **45 handlers** (17 core + 28 admin)
- **5 middleware** files
---
## API Mapping Reference
### Context Methods
| Gin | Echo | Notes |
|-----|------|-------|
| `c.ShouldBindJSON(&req)` | `c.Bind(&req)` | Bind only, no validation |
| `c.ShouldBindJSON(&req)` | `c.Validate(&req)` | Validation only (call after Bind) |
| `c.Param("id")` | `c.Param("id")` | Same |
| `c.Query("name")` | `c.QueryParam("name")` | Different method name |
| `c.DefaultQuery("k","v")` | Custom helper | No built-in equivalent |
| `c.PostForm("field")` | `c.FormValue("field")` | Different method name |
| `c.FormFile("file")` | `c.FormFile("file")` | Same |
| `c.Get(key)` | `c.Get(key)` | Same |
| `c.Set(key, val)` | `c.Set(key, val)` | Same |
| `c.MustGet(key)` | `c.Get(key)` | No MustGet, add nil check |
| `c.JSON(status, obj)` | `return c.JSON(status, obj)` | Must return |
| `c.AbortWithStatusJSON()` | `return c.JSON()` | Return-based flow |
| `c.Status(200)` | `return c.NoContent(200)` | Different method |
| `c.GetHeader("X-...")` | `c.Request().Header.Get()` | Access via Request |
| `c.ClientIP()` | `c.RealIP()` | Different method name |
| `c.File(path)` | `return c.File(path)` | Must return |
| `gin.H{...}` | `echo.Map{...}` | Or `map[string]any{}` |
### Handler Signature
```go
// Gin - void return
func (h *Handler) Method(c *gin.Context) {
// ...
c.JSON(200, response)
}
// Echo - error return (MUST return)
func (h *Handler) Method(c echo.Context) error {
// ...
return c.JSON(200, response)
}
```
### Middleware Signature
```go
// Gin
func MyMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// before
c.Next()
// after
}
}
// Echo
func MyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// before
err := next(c)
// after
return err
}
}
}
```
---
## Validation Changes
### Tag Migration
Change all `binding:` tags to `validate:` tags:
```go
// Before
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// After
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
```
### Supported Validation Tags
| Tag | Description |
|-----|-------------|
| `required` | Field must be present and non-zero |
| `required_without=Field` | Required if other field is empty |
| `omitempty` | Skip validation if empty |
| `email` | Must be valid email format |
| `min=N` | Minimum length/value |
| `max=N` | Maximum length/value |
| `len=N` | Exact length |
| `oneof=a b c` | Must be one of listed values |
| `url` | Must be valid URL |
| `uuid` | Must be valid UUID |
### New Error Response Format
```json
{
"error": "Validation failed",
"fields": {
"email": {
"message": "Must be a valid email address",
"tag": "email"
},
"password": {
"message": "Must be at least 8 characters",
"tag": "min"
}
}
}
```
**Mobile clients must update** error parsing to handle the `fields` object.
---
## Handler Migration Pattern
### Before (Gin)
```go
func (h *AuthHandler) Login(c *gin.Context) {
var req requests.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
})
return
}
user := c.MustGet(middleware.AuthUserKey).(*models.User)
response, err := h.authService.Login(&req)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
```
### After (Echo)
```go
func (h *AuthHandler) Login(c echo.Context) error {
var req requests.LoginRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.authService.Login(&req)
if err != nil {
return c.JSON(http.StatusUnauthorized, echo.Map{"error": err.Error()})
}
return c.JSON(http.StatusOK, response)
}
```
---
## Middleware Migration Examples
### Auth Middleware
```go
// Before (Gin)
func (m *AuthMiddleware) TokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := extractToken(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
user, err := m.validateToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Set(AuthUserKey, user)
c.Next()
}
}
// After (Echo)
func (m *AuthMiddleware) TokenAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token, err := extractToken(c)
if err != nil {
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "Unauthorized"})
}
user, err := m.validateToken(token)
if err != nil {
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "Invalid token"})
}
c.Set(AuthUserKey, user)
return next(c)
}
}
}
```
### Timezone Middleware
```go
// Before (Gin)
func TimezoneMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tzName := c.GetHeader(TimezoneHeader)
loc := parseTimezone(tzName)
c.Set(TimezoneKey, loc)
c.Next()
}
}
// After (Echo)
func TimezoneMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
tzName := c.Request().Header.Get(TimezoneHeader)
loc := parseTimezone(tzName)
c.Set(TimezoneKey, loc)
return next(c)
}
}
}
```
---
## Router Setup
### Before (Gin)
```go
func SetupRouter(deps *Dependencies) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
r.Use(cors.New(cors.Config{...}))
api := r.Group("/api")
api.POST("/auth/login/", authHandler.Login)
protected := api.Group("")
protected.Use(authMiddleware.TokenAuth())
protected.GET("/residences/", residenceHandler.List)
return r
}
```
### After (Echo)
```go
func SetupRouter(deps *Dependencies) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.Validator = validator.NewCustomValidator()
// Trailing slash handling
e.Pre(middleware.AddTrailingSlash())
e.Use(middleware.Recover())
e.Use(middleware.Logger())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{...}))
api := e.Group("/api")
api.POST("/auth/login/", authHandler.Login)
protected := api.Group("")
protected.Use(authMiddleware.TokenAuth())
protected.GET("/residences/", residenceHandler.List)
return e
}
```
---
## Helper Functions
### DefaultQuery Helper
```go
// internal/echohelpers/helpers.go
func DefaultQuery(c echo.Context, key, defaultValue string) string {
if val := c.QueryParam(key); val != "" {
return val
}
return defaultValue
}
```
### Safe Context Get
```go
// internal/middleware/helpers.go
func GetAuthUser(c echo.Context) *models.User {
val := c.Get(AuthUserKey)
if val == nil {
return nil
}
user, ok := val.(*models.User)
if !ok {
return nil
}
return user
}
func MustGetAuthUser(c echo.Context) (*models.User, error) {
user := GetAuthUser(c)
if user == nil {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "Authentication required")
}
return user, nil
}
```
---
## Testing Changes
### Test Setup
```go
// Before (Gin)
func SetupTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
return gin.New()
}
// After (Echo)
func SetupTestRouter() *echo.Echo {
e := echo.New()
e.Validator = validator.NewCustomValidator()
return e
}
```
### Making Test Requests
```go
// Before (Gin)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/auth/login/", body)
router.ServeHTTP(w, req)
// After (Echo) - Same pattern works
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/auth/login/", body)
e.ServeHTTP(rec, req)
```
---
## Important Notes
1. **All handlers must return error** - Echo uses return-based flow
2. **Trailing slashes** - Use `middleware.AddTrailingSlash()` to maintain API compatibility
3. **Type assertions** - Always add nil checks when using `c.Get()`
4. **CORS** - Use Echo's built-in CORS middleware
5. **Bind vs Validate** - Echo separates these; call both for full validation
---
## Files Modified
| Category | Count |
|----------|-------|
| New files | 2 (`validator/`, `echohelpers/`) |
| DTOs | 6 |
| Middleware | 5 |
| Core handlers | 14 |
| Admin handlers | 28 |
| Router | 2 |
| Tests | 6 |
| **Total** | **63** |

View File

@@ -2350,6 +2350,121 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
/subscription/checkout/:
post:
tags: [Subscriptions]
operationId: createCheckoutSession
summary: Create a Stripe Checkout session for web subscription purchase
security:
- tokenAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [price_id, success_url, cancel_url]
properties:
price_id:
type: string
description: Stripe Price ID
success_url:
type: string
format: uri
cancel_url:
type: string
format: uri
responses:
'200':
description: Checkout session created
content:
application/json:
schema:
type: object
properties:
checkout_url:
type: string
format: uri
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
description: Already subscribed on another platform
content:
application/json:
schema:
type: object
properties:
error:
type: string
existing_platform:
type: string
message:
type: string
/subscription/portal/:
post:
tags: [Subscriptions]
operationId: createPortalSession
summary: Create a Stripe Customer Portal session for managing web subscriptions
security:
- tokenAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [return_url]
properties:
return_url:
type: string
format: uri
responses:
'200':
description: Portal session created
content:
application/json:
schema:
type: object
properties:
portal_url:
type: string
format: uri
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Unauthorized'
/subscription/webhook/stripe/:
post:
tags: [Subscriptions]
operationId: handleStripeWebhook
summary: Handle Stripe webhook events (server-to-server)
description: |
Receives Stripe webhook events for subscription lifecycle management.
Verifies the webhook signature using the configured signing secret.
No auth token required — uses Stripe signature verification.
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Webhook processed successfully
content:
application/json:
schema:
type: object
properties:
received:
type: boolean
'400':
$ref: '#/components/responses/Error'
# ===========================================================================
# Uploads
# ===========================================================================
@@ -4434,6 +4549,12 @@ components:
SubscriptionStatusResponse:
type: object
properties:
tier:
type: string
description: 'Subscription tier (free or pro)'
is_active:
type: boolean
description: Whether the subscription is currently active
subscribed_at:
type: string
format: date-time
@@ -4444,6 +4565,20 @@ components:
nullable: true
auto_renew:
type: boolean
trial_start:
type: string
format: date-time
nullable: true
trial_end:
type: string
format: date-time
nullable: true
trial_active:
type: boolean
subscription_source:
type: string
nullable: true
description: 'Platform source of the active subscription (ios, android, stripe, or null)'
usage:
$ref: '#/components/schemas/UsageResponse'
limits: