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:
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type AuthService struct {
|
||||
repo repository.AuthRepository
|
||||
plans repository.TenantPlanRepository
|
||||
kolProfiles repository.KolProfileRepository
|
||||
jwt *auth.Manager
|
||||
sessions *auth.SessionStore
|
||||
@@ -21,12 +22,14 @@ type AuthService struct {
|
||||
|
||||
func NewAuthService(
|
||||
repo repository.AuthRepository,
|
||||
plans repository.TenantPlanRepository,
|
||||
kolProfiles repository.KolProfileRepository,
|
||||
jwt *auth.Manager,
|
||||
sessions *auth.SessionStore,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
repo: repo,
|
||||
plans: plans,
|
||||
kolProfiles: kolProfiles,
|
||||
jwt: jwt,
|
||||
sessions: sessions,
|
||||
@@ -53,9 +56,25 @@ type UserInfo struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantRole string `json:"tenant_role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Membership *MembershipInfo `json:"membership"`
|
||||
KolProfile *KolProfileBrief `json:"kol_profile"`
|
||||
}
|
||||
|
||||
type MembershipInfo struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
PlanName string `json:"plan_name"`
|
||||
Status string `json:"status"`
|
||||
Blocked bool `json:"blocked"`
|
||||
BlockedReason *string `json:"blocked_reason"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
ArticleGenerationLimit int `json:"article_generation_limit"`
|
||||
ArticleQuotaCycle string `json:"article_quota_cycle"`
|
||||
CompanyLimit int `json:"company_limit"`
|
||||
ImageStorageBytes int64 `json:"image_storage_bytes"`
|
||||
ContactAdminOnExpiry bool `json:"contact_admin_on_expiry"`
|
||||
}
|
||||
|
||||
type KolProfileBrief struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
@@ -92,6 +111,10 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberInfo, err := s.loadMembershipInfo(ctx, membership.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
@@ -105,6 +128,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
TenantID: membership.TenantID,
|
||||
TenantRole: membership.TenantRole,
|
||||
Permissions: auth.PermissionsForRole(membership.TenantRole),
|
||||
Membership: memberInfo,
|
||||
KolProfile: kolProfile,
|
||||
},
|
||||
}, nil
|
||||
@@ -166,6 +190,10 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberInfo, err := s.loadMembershipInfo(ctx, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
ID: user.ID,
|
||||
@@ -175,6 +203,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
||||
TenantID: actor.TenantID,
|
||||
TenantRole: actor.Role,
|
||||
Permissions: auth.PermissionsForRole(actor.Role),
|
||||
Membership: memberInfo,
|
||||
KolProfile: kolProfile,
|
||||
}, nil
|
||||
}
|
||||
@@ -200,6 +229,59 @@ func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) loadMembershipInfo(ctx context.Context, tenantID int64) (*MembershipInfo, error) {
|
||||
if s.plans == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
access, err := s.plans.GetTenantPlanAccess(ctx, tenantID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if access == nil {
|
||||
reason := "subscription_required"
|
||||
return &MembershipInfo{
|
||||
Status: "inactive",
|
||||
Blocked: true,
|
||||
BlockedReason: &reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
status := "active"
|
||||
if !access.HasActiveAccess(now) {
|
||||
status = "expired"
|
||||
}
|
||||
blockedReason := access.BlockedReason(now)
|
||||
var blockedReasonPtr *string
|
||||
if blockedReason != "" {
|
||||
blockedReasonPtr = &blockedReason
|
||||
}
|
||||
|
||||
return &MembershipInfo{
|
||||
PlanCode: access.PlanCode,
|
||||
PlanName: access.PlanName,
|
||||
Status: status,
|
||||
Blocked: !access.HasActiveAccess(now),
|
||||
BlockedReason: blockedReasonPtr,
|
||||
StartAt: formatTimeValuePtr(access.StartAt),
|
||||
EndAt: formatTimeValuePtr(access.EndAt),
|
||||
ArticleGenerationLimit: access.ArticleGenerationLimit(),
|
||||
ArticleQuotaCycle: access.ArticleQuotaCycle(),
|
||||
CompanyLimit: access.BrandLimit(),
|
||||
ImageStorageBytes: access.ImageStorageBytes(),
|
||||
ContactAdminOnExpiry: access.Policy.ContactAdminOnExpiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatTimeValuePtr(value time.Time) *string {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
formatted := value.UTC().Format(time.RFC3339)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
|
||||
claims, err := s.jwt.Parse(accessToken)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -771,6 +771,7 @@ 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 s.end_at > NOW()
|
||||
LIMIT 1
|
||||
`, tenantID, defaultImageQuotaBytes).Scan("aBytes)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@@ -18,13 +19,23 @@ import (
|
||||
type WorkspaceService struct {
|
||||
repo repository.WorkspaceRepository
|
||||
templates repository.TemplateRepository
|
||||
quota repository.QuotaRepository
|
||||
supportedPlatformCount int
|
||||
cache sharedcache.Cache
|
||||
cacheGroup singleflight.Group
|
||||
}
|
||||
|
||||
func NewWorkspaceService(repo repository.WorkspaceRepository, templates repository.TemplateRepository) *WorkspaceService {
|
||||
return &WorkspaceService{repo: repo, templates: templates, supportedPlatformCount: 5}
|
||||
func NewWorkspaceService(
|
||||
repo repository.WorkspaceRepository,
|
||||
templates repository.TemplateRepository,
|
||||
quota repository.QuotaRepository,
|
||||
) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
repo: repo,
|
||||
templates: templates,
|
||||
quota: quota,
|
||||
supportedPlatformCount: 5,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) WithCache(c sharedcache.Cache) *WorkspaceService {
|
||||
@@ -87,29 +98,21 @@ func (s *WorkspaceService) RecentArticles(ctx context.Context) ([]domain.RecentA
|
||||
func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSummary, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceQuotaSummaryCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) (*domain.QuotaSummary, error) {
|
||||
balance, err := s.repo.GetQuotaSummary(loadCtx, actor.TenantID)
|
||||
status, err := s.quota.GetArticleQuotaStatus(loadCtx, actor.TenantID, time.Now().UTC())
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &domain.QuotaSummary{Balance: 0}, nil
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance")
|
||||
}
|
||||
|
||||
plan, err := s.repo.GetActivePlanForTenant(loadCtx, actor.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &domain.QuotaSummary{Balance: balance}, nil
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to get active plan")
|
||||
}
|
||||
|
||||
var policy map[string]int
|
||||
_ = json.Unmarshal(plan.QuotaPolicyJSON, &policy)
|
||||
total := policy["article_generation"]
|
||||
|
||||
return &domain.QuotaSummary{
|
||||
PlanCode: plan.PlanCode,
|
||||
PlanName: plan.PlanName,
|
||||
TotalQuota: total,
|
||||
UsedQuota: total - balance,
|
||||
Balance: balance,
|
||||
PlanCode: status.PlanCode,
|
||||
PlanName: status.PlanName,
|
||||
TotalQuota: status.Total,
|
||||
UsedQuota: status.Used,
|
||||
Balance: status.Balance,
|
||||
ResetAt: status.ResetAt,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user