feat(membership): enforce tenant subscription plans with blocked UI
Add configurable membership plans (free/plus/pro) synced to DB on boot, a subscription guard middleware that blocks tenant endpoints on expired or missing plans, and a MembershipBlockedView that surfaces the reason so the admin can contact the tenant owner. Quota and brand-library reads now honor the active plan's policy JSON and expired subscriptions.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -787,8 +788,9 @@ func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brand
|
||||
}
|
||||
|
||||
type brandLibraryPlan struct {
|
||||
PlanCode string
|
||||
PlanName string
|
||||
PlanCode string
|
||||
PlanName string
|
||||
MaxBrands int
|
||||
}
|
||||
|
||||
type brandLibraryUsage struct {
|
||||
@@ -808,6 +810,9 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
|
||||
}
|
||||
|
||||
maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode)
|
||||
if plan.MaxBrands > 0 {
|
||||
maxBrands = plan.MaxBrands
|
||||
}
|
||||
maxKeywords := s.limits.MaxKeywords
|
||||
|
||||
return &BrandLibrarySummaryResponse{
|
||||
@@ -825,26 +830,39 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int
|
||||
|
||||
func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) {
|
||||
plan := &brandLibraryPlan{
|
||||
PlanCode: "free",
|
||||
PlanName: "",
|
||||
PlanCode: "free",
|
||||
PlanName: "",
|
||||
MaxBrands: s.limits.BrandLimitForPlan("free"),
|
||||
}
|
||||
|
||||
var quotaPolicyJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT p.plan_code, p.name
|
||||
SELECT p.plan_code, p.name, p.quota_policy_json
|
||||
FROM tenant_plan_subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.status = 'active'
|
||||
AND s.deleted_at IS NULL
|
||||
AND p.status = 'active'
|
||||
AND s.end_at > $2
|
||||
ORDER BY s.start_at DESC
|
||||
LIMIT 1
|
||||
`, tenantID).Scan(&plan.PlanCode, &plan.PlanName)
|
||||
`, tenantID, time.Now().UTC()).Scan(&plan.PlanCode, &plan.PlanName, "aPolicyJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return plan, nil
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to load active plan")
|
||||
}
|
||||
var quotaPolicy struct {
|
||||
BrandLimit int `json:"brand_limit"`
|
||||
}
|
||||
if len(quotaPolicyJSON) > 0 {
|
||||
_ = json.Unmarshal(quotaPolicyJSON, "aPolicy)
|
||||
}
|
||||
if quotaPolicy.BrandLimit > 0 {
|
||||
plan.MaxBrands = quotaPolicy.BrandLimit
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user