diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 8e295db..615e2b1 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -95,6 +95,22 @@ const enUS = { defaultTestAccount: "Default account", defaultTestPassword: "Default password", }, + membershipBlocked: { + eyebrow: "Access Paused", + title: "This tenant plan is unavailable", + reasonTrialExpired: "The free trial's 3-day page access window has ended. All business pages are now locked. Please contact an administrator to reactivate trial access.", + reasonRequired: "This tenant does not have an active plan. All business pages are now locked. Please contact an administrator.", + reasonInactive: "This tenant plan has expired or been disabled. All business pages are now locked. Please contact an administrator to restore access.", + plan: "Current Plan", + expiredAt: "Expired At", + articleLimit: "Article Limit", + companyLimit: "Company Limit", + contactAdmin: "Once the administrator reactivates access, refresh this page to continue.", + logout: "Log out", + refresh: "Check again", + refreshing: "Checking…", + stillBlocked: "Access is still blocked. Please contact your administrator before checking again.", + }, shell: { localeLabel: "Language", quotaTitle: "Plan & Quota", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 74ed22e..3d086a3 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -95,6 +95,22 @@ const zhCN = { defaultTestAccount: "默认测试账号", defaultTestPassword: "默认测试密码", }, + membershipBlocked: { + eyebrow: "访问已暂停", + title: "当前租户套餐不可用", + reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。", + reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。", + reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。", + plan: "当前套餐", + expiredAt: "到期时间", + articleLimit: "文章额度", + companyLimit: "公司数量", + contactAdmin: "管理员重新开通后,刷新页面即可恢复访问。", + logout: "退出登录", + refresh: "重新检查", + refreshing: "检查中…", + stillBlocked: "账户仍处于受限状态,请先联系管理员后再重试。", + }, shell: { localeLabel: "语言", quotaTitle: "套餐与额度", diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index fc23f16..ec526e1 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -12,6 +12,9 @@ const errorMessageMap: Record = { tenant_scope_missing: "租户上下文缺失", query_failed: "数据读取失败", quota_insufficient: "生成额度不足", + trial_plan_expired: "免费试用已到期,请联系管理员开通", + subscription_required: "当前租户尚未开通有效套餐,请联系管理员", + subscription_inactive: "当前租户套餐已失效,请联系管理员", llm_unavailable: "生成服务不可用", analyze_context_required: "请先填写品牌或模板上下文信息", title_context_required: "请先确认品牌、关键词或竞品信息", diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index 5fc486b..5634257 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -15,6 +15,14 @@ const router = createRouter({ descriptionKey: "route.login.description", }, }, + { + path: "/membership-blocked", + name: "membership-blocked", + component: () => import("@/views/MembershipBlockedView.vue"), + meta: { + requiresAuth: true, + }, + }, { path: "/", component: () => import("@/layouts/AppShell.vue"), @@ -223,11 +231,22 @@ router.beforeEach(async (to) => { }; } + if (authStore.isAuthenticated && authStore.isMembershipBlocked && to.name !== "membership-blocked") { + return { name: "membership-blocked" }; + } + + if (to.name === "membership-blocked" && authStore.isAuthenticated && !authStore.isMembershipBlocked) { + return { name: "workspace" }; + } + if (to.meta.requiresKol && !authStore.isActiveKol) { return { name: "workspace" }; } if (to.name === "login" && authStore.isAuthenticated) { + if (authStore.isMembershipBlocked) { + return { name: "membership-blocked" }; + } return { name: "workspace" }; } diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts index b19a424..9f76186 100644 --- a/apps/admin-web/src/stores/auth.ts +++ b/apps/admin-web/src/stores/auth.ts @@ -65,6 +65,8 @@ export const useAuthStore = defineStore("auth", () => { } const isAuthenticated = computed(() => Boolean(accessToken.value && user.value)); + const membership = computed(() => user.value?.membership ?? null); + const isMembershipBlocked = computed(() => Boolean(membership.value?.blocked)); const kolProfile = computed(() => user.value?.kol_profile ?? null); const isActiveKol = computed(() => kolProfile.value?.status === "active"); @@ -74,6 +76,8 @@ export const useAuthStore = defineStore("auth", () => { user, initialized, isAuthenticated, + membership, + isMembershipBlocked, kolProfile, isActiveKol, bootstrap, diff --git a/apps/admin-web/src/views/MembershipBlockedView.vue b/apps/admin-web/src/views/MembershipBlockedView.vue new file mode 100644 index 0000000..7848030 --- /dev/null +++ b/apps/admin-web/src/views/MembershipBlockedView.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/deploy/config.yaml b/deploy/config.yaml index b28792e..9341cb5 100644 --- a/deploy/config.yaml +++ b/deploy/config.yaml @@ -38,6 +38,32 @@ monitoring_workers: result_ingest_concurrency: 4 projection_rebuild_concurrency: 2 +membership: + default_plan_code: free + plans: + - code: free + name: Free + article_generation: 3 + article_quota_cycle: lifetime + image_storage_bytes: 10485760 + company_limit: 1 + subscription_duration: 72h + contact_admin_on_expiry: true + - code: plus + name: Plus + article_generation: 200 + article_quota_cycle: monthly + image_storage_bytes: 524288000 + company_limit: 1 + subscription_duration: 720h + - code: pro + name: Pro + article_generation: 400 + article_quota_cycle: monthly + image_storage_bytes: 1073741824 + company_limit: 2 + subscription_duration: 720h + redis: addr: redis:6379 db: 0 diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 4cc4d0d..2b5aa29 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -30,9 +30,25 @@ export interface UserInfo { tenant_id: number; tenant_role: string; permissions: string[]; + membership: MembershipInfo | null; kol_profile: KolProfileBrief | null; } +export interface MembershipInfo { + plan_code: string; + plan_name: string; + status: string; + blocked: boolean; + blocked_reason: string | null; + start_at: string | null; + end_at: string | null; + article_generation_limit: number; + article_quota_cycle: string; + company_limit: number; + image_storage_bytes: number; + contact_admin_on_expiry: boolean; +} + export interface LoginResponse { access_token: string; refresh_token: string; diff --git a/server/cmd/dev-seed/main.go b/server/cmd/dev-seed/main.go index 5fc1568..d069f57 100644 --- a/server/cmd/dev-seed/main.go +++ b/server/cmd/dev-seed/main.go @@ -18,6 +18,7 @@ import ( sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/tenant/prompts" + "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type seedState struct { @@ -119,7 +120,7 @@ func main() { log.Fatalf("ping monitoring db: %v", err) } - state, err := seedBusinessData(ctx, businessPool) + state, err := seedBusinessData(ctx, businessPool, cfg.Membership) if err != nil { log.Fatalf("seed business data: %v", err) } @@ -140,7 +141,7 @@ func main() { fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts") } -func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, error) { +func seedBusinessData(ctx context.Context, pool *pgxpool.Pool, membershipCfg config.MembershipConfig) (*seedState, error) { hash, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) @@ -156,6 +157,18 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, erro state := &seedState{} + if err := repository.NewTenantPlanRepository(tx).UpsertConfiguredPlans(ctx, membershipCfg); err != nil { + return nil, fmt.Errorf("sync membership plans: %w", err) + } + + defaultPlan, ok := membershipCfg.FindPlan(membershipCfg.DefaultPlanCode) + if !ok { + defaultPlan, _ = membershipCfg.FindPlan("free") + } + if defaultPlan.Code == "" && len(membershipCfg.Plans) > 0 { + defaultPlan = membershipCfg.Plans[0] + } + state.TenantID, err = ensureTenant(ctx, tx) if err != nil { return nil, err @@ -167,20 +180,20 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, erro if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil { return nil, err } - state.PlanID, err = ensurePlan(ctx, tx) + state.PlanID, err = ensurePlan(ctx, tx, defaultPlan.Code) if err != nil { return nil, err } - if err := ensureSubscription(ctx, tx, state.TenantID, state.PlanID); err != nil { + if err := ensureSubscription(ctx, tx, state.TenantID, state.PlanID, defaultPlan.SubscriptionDuration); err != nil { return nil, err } - if err := ensureQuotaLedger(ctx, tx, state.TenantID); err != nil { + if err := ensureQuotaLedger(ctx, tx, state.TenantID, defaultPlan.ArticleGeneration); err != nil { return nil, err } if err := ensureTemplates(ctx, tx); err != nil { return nil, err } - state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID) + state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID, defaultPlan) if err != nil { return nil, err } @@ -342,7 +355,12 @@ func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID i return wrapSeedErr("insert membership", err) } -func ensureSecondaryTenantUser(ctx context.Context, tx pgx.Tx, planID int64) (int64, int64, error) { +func ensureSecondaryTenantUser( + ctx context.Context, + tx pgx.Tx, + planID int64, + defaultPlan config.MembershipPlanConfig, +) (int64, int64, error) { hash, err := bcrypt.GenerateFromPassword([]byte("Test@123"), bcrypt.DefaultCost) if err != nil { return 0, 0, fmt.Errorf("hash secondary user password: %w", err) @@ -361,52 +379,52 @@ func ensureSecondaryTenantUser(ctx context.Context, tx pgx.Tx, planID int64) (in if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil { return 0, 0, err } - if err := ensureSubscription(ctx, tx, tenantID, planID); err != nil { + if err := ensureSubscription(ctx, tx, tenantID, planID, defaultPlan.SubscriptionDuration); err != nil { return 0, 0, err } - if err := ensureQuotaLedger(ctx, tx, tenantID); err != nil { + if err := ensureQuotaLedger(ctx, tx, tenantID, defaultPlan.ArticleGeneration); err != nil { return 0, 0, err } return tenantID, userID, nil } -func ensurePlan(ctx context.Context, tx pgx.Tx) (int64, error) { +func ensurePlan(ctx context.Context, tx pgx.Tx, planCode string) (int64, error) { var planID int64 err := tx.QueryRow(ctx, ` - WITH ensured AS ( - INSERT INTO plans (plan_code, name, quota_policy_json, status) - VALUES ('free', 'Free Plan', '{"article_generation": 100}', 'active') - ON CONFLICT DO NOTHING - RETURNING id - ) - SELECT id FROM ensured - UNION ALL - SELECT id FROM plans WHERE plan_code = 'free' + SELECT id + FROM plans + WHERE plan_code = $1 + AND deleted_at IS NULL LIMIT 1 - `).Scan(&planID) + `, planCode).Scan(&planID) return planID, wrapSeedErr("insert plan", err) } -func ensureSubscription(ctx context.Context, tx pgx.Tx, tenantID, planID int64) error { +func ensureSubscription(ctx context.Context, tx pgx.Tx, tenantID, planID int64, duration time.Duration) error { + if duration <= 0 { + duration = 72 * time.Hour + } _, err := tx.Exec(ctx, ` INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status) - SELECT $1, $2, NOW(), NOW() + INTERVAL '1 year', 'active' + SELECT $1, $2, NOW(), NOW() + $3::interval, 'active' WHERE NOT EXISTS ( SELECT 1 FROM tenant_plan_subscriptions WHERE tenant_id = $1 AND plan_id = $2 AND status = 'active' + AND deleted_at IS NULL + AND end_at > NOW() ) - `, tenantID, planID) + `, tenantID, planID, duration.String()) return wrapSeedErr("insert subscription", err) } -func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64) error { +func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64, total int) error { _, err := tx.Exec(ctx, ` INSERT INTO tenant_quota_ledgers (tenant_id, quota_type, delta, balance_after, reason) - SELECT $1, 'article_generation', 100, 100, 'initial_allocation' + SELECT $1, 'article_generation', $2, $2, 'initial_allocation' WHERE NOT EXISTS ( SELECT 1 FROM tenant_quota_ledgers @@ -414,7 +432,7 @@ func ensureQuotaLedger(ctx context.Context, tx pgx.Tx, tenantID int64) error { AND quota_type = 'article_generation' AND reason = 'initial_allocation' ) - `, tenantID) + `, tenantID, total) return wrapSeedErr("insert quota", err) } diff --git a/server/configs/config.local.yaml.example b/server/configs/config.local.yaml.example index 23de1dc..80e891e 100644 --- a/server/configs/config.local.yaml.example +++ b/server/configs/config.local.yaml.example @@ -59,6 +59,32 @@ monitoring_workers: result_ingest_concurrency: 4 projection_rebuild_concurrency: 2 +membership: + default_plan_code: free + plans: + - code: free + name: Free + article_generation: 3 + article_quota_cycle: lifetime + image_storage_bytes: 10485760 + company_limit: 1 + subscription_duration: 72h + contact_admin_on_expiry: true + - code: plus + name: Plus + article_generation: 200 + article_quota_cycle: monthly + image_storage_bytes: 524288000 + company_limit: 1 + subscription_duration: 720h + - code: pro + name: Pro + article_generation: 400 + article_quota_cycle: monthly + image_storage_bytes: 1073741824 + company_limit: 2 + subscription_duration: 720h + brand_library: free_brand_limit: 1 paid_brand_limit: 2 diff --git a/server/configs/config.yaml b/server/configs/config.yaml index 5981dff..300abcd 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -69,6 +69,32 @@ monitoring_workers: result_ingest_concurrency: 4 projection_rebuild_concurrency: 2 +membership: + default_plan_code: free + plans: + - code: free + name: Free + article_generation: 3 + article_quota_cycle: lifetime + image_storage_bytes: 10485760 + company_limit: 1 + subscription_duration: 72h + contact_admin_on_expiry: true + - code: plus + name: Plus + article_generation: 200 + article_quota_cycle: monthly + image_storage_bytes: 524288000 + company_limit: 1 + subscription_duration: 720h + - code: pro + name: Pro + article_generation: 400 + article_quota_cycle: monthly + image_storage_bytes: 1073741824 + company_limit: 2 + subscription_duration: 720h + brand_library: free_brand_limit: 1 paid_brand_limit: 2 diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go index 25add98..02ae8d0 100644 --- a/server/internal/bootstrap/bootstrap.go +++ b/server/internal/bootstrap/bootstrap.go @@ -75,6 +75,11 @@ func New(configPath string) (*App, error) { return nil, fmt.Errorf("init postgres: %w", err) } + if err := syncMembershipPlans(ctx, pool, cfg.Membership); err != nil { + pool.Close() + return nil, fmt.Errorf("sync membership plans: %w", err) + } + monitoringPool, err := postgres.NewPool(ctx, cfg.MonitoringDatabase) if err != nil { pool.Close() @@ -175,6 +180,24 @@ func New(configPath string) (*App, error) { }, nil } +// syncMembershipPlans upserts all configured plans in a single transaction so +// that a partial failure (or concurrent startup) never leaves the `plans` table +// in a half-synced state. +func syncMembershipPlans(ctx context.Context, pool *pgxpool.Pool, membership config.MembershipConfig) error { + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if err := repository.NewTenantPlanRepository(tx).UpsertConfiguredPlans(ctx, membership); err != nil { + return err + } + return tx.Commit(ctx) +} + func (a *App) Close() { if a.AuditLogs != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 08a9e87..d56659a 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "os" "strings" @@ -16,6 +17,7 @@ type Config struct { RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"` Scheduler SchedulerConfig `mapstructure:"scheduler"` MonitoringWorkers MonitoringConfig `mapstructure:"monitoring_workers"` + Membership MembershipConfig `mapstructure:"membership"` BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"` Redis RedisConfig `mapstructure:"redis"` Qdrant QdrantConfig `mapstructure:"qdrant"` @@ -110,6 +112,64 @@ type MonitoringConfig struct { ProjectionRebuildConcurrency int `mapstructure:"projection_rebuild_concurrency"` } +const ( + ArticleQuotaCycleLifetime = "lifetime" + ArticleQuotaCycleMonthly = "monthly" +) + +type MembershipConfig struct { + DefaultPlanCode string `mapstructure:"default_plan_code"` + Plans []MembershipPlanConfig `mapstructure:"plans"` +} + +type MembershipPlanConfig struct { + Code string `mapstructure:"code"` + Name string `mapstructure:"name"` + ArticleGeneration int `mapstructure:"article_generation"` + ArticleQuotaCycle string `mapstructure:"article_quota_cycle"` + ImageStorageBytes int64 `mapstructure:"image_storage_bytes"` + CompanyLimit int `mapstructure:"company_limit"` + SubscriptionDuration time.Duration `mapstructure:"subscription_duration"` + ContactAdminOnExpiry bool `mapstructure:"contact_admin_on_expiry"` +} + +type PlanQuotaPolicy struct { + ArticleGeneration int `json:"article_generation"` + ArticleQuotaCycle string `json:"article_quota_cycle"` + ImageStorageBytes int64 `json:"image_storage_bytes"` + BrandLimit int `json:"brand_limit"` + SubscriptionDurationSecs int64 `json:"subscription_duration_seconds,omitempty"` + ContactAdminOnExpiry bool `json:"contact_admin_on_expiry,omitempty"` +} + +func (c MembershipConfig) FindPlan(code string) (MembershipPlanConfig, bool) { + normalizedCode := strings.ToLower(strings.TrimSpace(code)) + for _, plan := range c.Plans { + if strings.EqualFold(strings.TrimSpace(plan.Code), normalizedCode) { + return plan, true + } + } + return MembershipPlanConfig{}, false +} + +func (p MembershipPlanConfig) QuotaPolicy() PlanQuotaPolicy { + policy := PlanQuotaPolicy{ + ArticleGeneration: maxInt(p.ArticleGeneration, 0), + ArticleQuotaCycle: normalizeArticleQuotaCycle(p.ArticleQuotaCycle, p.Code), + ImageStorageBytes: maxInt64(p.ImageStorageBytes, 0), + BrandLimit: maxInt(p.CompanyLimit, 0), + ContactAdminOnExpiry: p.ContactAdminOnExpiry, + } + if p.SubscriptionDuration > 0 { + policy.SubscriptionDurationSecs = int64(p.SubscriptionDuration / time.Second) + } + return policy +} + +func (p MembershipPlanConfig) QuotaPolicyJSON() ([]byte, error) { + return json.Marshal(p.QuotaPolicy()) +} + type BrandLibraryConfig struct { FreeBrandLimit int `mapstructure:"free_brand_limit"` PaidBrandLimit int `mapstructure:"paid_brand_limit"` @@ -196,17 +256,15 @@ type GenerationConfig struct { func Load(configPath string) (*Config, error) { v := viper.New() - v.SetConfigFile(configPath) v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - if err := v.ReadInConfig(); err != nil { + if err := readConfigWithFallback(v, configPath); err != nil { return nil, fmt.Errorf("read config: %w", err) } // Allow local overrides - v.SetConfigFile(strings.Replace(configPath, ".yaml", ".local.yaml", 1)) - _ = v.MergeInConfig() + _ = mergeLocalOverrideWithFallback(v, configPath) var cfg Config if err := v.Unmarshal(&cfg); err != nil { @@ -217,11 +275,77 @@ func Load(configPath string) (*Config, error) { normalizeRabbitMQConfig(&cfg.RabbitMQ) normalizeSchedulerConfig(&cfg.Scheduler) normalizeMonitoringConfig(&cfg.MonitoringWorkers) + normalizeMembershipConfig(&cfg.Membership) normalizeBrandLibraryConfig(&cfg.BrandLibrary) return &cfg, nil } +func readConfigWithFallback(v *viper.Viper, configPath string) error { + var lastErr error + for _, candidate := range candidateConfigPaths(configPath, false) { + v.SetConfigFile(candidate) + if err := v.ReadInConfig(); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr +} + +func mergeLocalOverrideWithFallback(v *viper.Viper, configPath string) error { + for _, candidate := range candidateConfigPaths(configPath, true) { + v.SetConfigFile(candidate) + if err := v.MergeInConfig(); err == nil { + return nil + } + } + return nil +} + +func candidateConfigPaths(configPath string, local bool) []string { + trimmed := strings.TrimSpace(configPath) + if trimmed == "" { + return nil + } + + paths := make([]string, 0, 2) + switch { + case strings.HasSuffix(trimmed, ".yaml"): + if local { + paths = append(paths, strings.TrimSuffix(trimmed, ".yaml")+".local.yaml") + } else { + paths = append(paths, trimmed) + } + alt := strings.TrimSuffix(trimmed, ".yaml") + if local { + paths = append(paths, alt+".local.yml") + } else { + paths = append(paths, alt+".yml") + } + case strings.HasSuffix(trimmed, ".yml"): + if local { + paths = append(paths, strings.TrimSuffix(trimmed, ".yml")+".local.yml") + } else { + paths = append(paths, trimmed) + } + alt := strings.TrimSuffix(trimmed, ".yml") + if local { + paths = append(paths, alt+".local.yaml") + } else { + paths = append(paths, alt+".yaml") + } + default: + if local { + paths = append(paths, trimmed+".local.yaml", trimmed+".local.yml") + } else { + paths = append(paths, trimmed, trimmed+".yaml", trimmed+".yml") + } + } + return paths +} + func applyEnvOverrides(cfg *Config) { if cfg == nil { return @@ -457,6 +581,119 @@ func normalizeBrandLibraryConfig(cfg *BrandLibraryConfig) { } } +func normalizeMembershipConfig(cfg *MembershipConfig) { + if cfg == nil { + return + } + + if strings.TrimSpace(cfg.DefaultPlanCode) == "" { + cfg.DefaultPlanCode = "free" + } + + if len(cfg.Plans) == 0 { + cfg.Plans = defaultMembershipPlans() + } + + normalized := make([]MembershipPlanConfig, 0, len(cfg.Plans)) + seen := make(map[string]struct{}, len(cfg.Plans)) + for _, plan := range cfg.Plans { + plan.Code = strings.ToLower(strings.TrimSpace(plan.Code)) + if plan.Code == "" { + continue + } + if _, exists := seen[plan.Code]; exists { + continue + } + if strings.TrimSpace(plan.Name) == "" { + plan.Name = defaultPlanName(plan.Code) + } + plan.ArticleQuotaCycle = normalizeArticleQuotaCycle(plan.ArticleQuotaCycle, plan.Code) + if plan.ArticleGeneration < 0 { + plan.ArticleGeneration = 0 + } + if plan.ImageStorageBytes < 0 { + plan.ImageStorageBytes = 0 + } + if plan.CompanyLimit < 0 { + plan.CompanyLimit = 0 + } + if plan.SubscriptionDuration < 0 { + plan.SubscriptionDuration = 0 + } + normalized = append(normalized, plan) + seen[plan.Code] = struct{}{} + } + + if len(normalized) == 0 { + normalized = defaultMembershipPlans() + } + cfg.Plans = normalized + + if _, ok := cfg.FindPlan(cfg.DefaultPlanCode); !ok { + cfg.DefaultPlanCode = cfg.Plans[0].Code + } +} + +func defaultMembershipPlans() []MembershipPlanConfig { + return []MembershipPlanConfig{ + { + Code: "free", + Name: "Free", + ArticleGeneration: 3, + ArticleQuotaCycle: ArticleQuotaCycleLifetime, + ImageStorageBytes: 10 * 1024 * 1024, + CompanyLimit: 1, + SubscriptionDuration: 72 * time.Hour, + ContactAdminOnExpiry: true, + }, + { + Code: "plus", + Name: "Plus", + ArticleGeneration: 200, + ArticleQuotaCycle: ArticleQuotaCycleMonthly, + ImageStorageBytes: 500 * 1024 * 1024, + CompanyLimit: 1, + SubscriptionDuration: 720 * time.Hour, + }, + { + Code: "pro", + Name: "Pro", + ArticleGeneration: 400, + ArticleQuotaCycle: ArticleQuotaCycleMonthly, + ImageStorageBytes: 1024 * 1024 * 1024, + CompanyLimit: 2, + SubscriptionDuration: 720 * time.Hour, + }, + } +} + +func normalizeArticleQuotaCycle(value, planCode string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case ArticleQuotaCycleLifetime: + return ArticleQuotaCycleLifetime + case ArticleQuotaCycleMonthly: + return ArticleQuotaCycleMonthly + default: + if strings.EqualFold(strings.TrimSpace(planCode), "free") { + return ArticleQuotaCycleLifetime + } + return ArticleQuotaCycleMonthly + } +} + +func defaultPlanName(code string) string { + switch strings.ToLower(strings.TrimSpace(code)) { + case "free": + return "Free" + case "plus": + return "Plus" + case "pro": + return "Pro" + default: + return strings.ToUpper(strings.TrimSpace(code)) + } +} + func lookupFirstNonEmptyEnv(keys ...string) (string, bool) { for _, key := range keys { if value, ok := lookupNonEmptyEnv(key); ok { @@ -495,3 +732,17 @@ func lookupBoolEnv(key string) (bool, bool) { return false, false } } + +func maxInt(value, fallback int) int { + if value < fallback { + return fallback + } + return value +} + +func maxInt64(value, fallback int64) int64 { + if value < fallback { + return fallback + } + return value +} diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index f50d46e..fe584f9 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestLoadPrefersEnvLLMAPIKey(t *testing.T) { @@ -113,6 +114,55 @@ brand_library: {} } } +func TestLoadAppliesMembershipDefaults(t *testing.T) { + configPath := writeTestConfig(t, ` +membership: {} +`) + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Membership.DefaultPlanCode != "free" { + t.Fatalf("expected default plan code free, got %q", cfg.Membership.DefaultPlanCode) + } + if len(cfg.Membership.Plans) != 3 { + t.Fatalf("expected 3 default plans, got %d", len(cfg.Membership.Plans)) + } + + free, ok := cfg.Membership.FindPlan("free") + if !ok { + t.Fatalf("expected free plan to exist") + } + if free.ArticleGeneration != 3 { + t.Fatalf("expected free article generation 3, got %d", free.ArticleGeneration) + } + if free.ImageStorageBytes != 10*1024*1024 { + t.Fatalf("expected free image storage 10MB, got %d", free.ImageStorageBytes) + } + if free.CompanyLimit != 1 { + t.Fatalf("expected free company limit 1, got %d", free.CompanyLimit) + } + if free.SubscriptionDuration != 72*time.Hour { + t.Fatalf("expected free subscription duration 72h, got %s", free.SubscriptionDuration) + } + if !free.ContactAdminOnExpiry { + t.Fatalf("expected free plan to require admin contact on expiry") + } + + pro, ok := cfg.Membership.FindPlan("pro") + if !ok { + t.Fatalf("expected pro plan to exist") + } + if pro.ArticleGeneration != 400 { + t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration) + } + if pro.CompanyLimit != 2 { + t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit) + } +} + func writeTestConfig(t *testing.T, body string) string { t.Helper() diff --git a/server/internal/tenant/app/auth_service.go b/server/internal/tenant/app/auth_service.go index 1e5bd58..3a034ce 100644 --- a/server/internal/tenant/app/auth_service.go +++ b/server/internal/tenant/app/auth_service.go @@ -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 { diff --git a/server/internal/tenant/app/brand_service.go b/server/internal/tenant/app/brand_service.go index ef8eb45..a83c0e8 100644 --- a/server/internal/tenant/app/brand_service.go +++ b/server/internal/tenant/app/brand_service.go @@ -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 } diff --git a/server/internal/tenant/app/image_service.go b/server/internal/tenant/app/image_service.go index 2e992b0..99b2d29 100644 --- a/server/internal/tenant/app/image_service.go +++ b/server/internal/tenant/app/image_service.go @@ -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 { diff --git a/server/internal/tenant/app/workspace_service.go b/server/internal/tenant/app/workspace_service.go index d877255..29e65cc 100644 --- a/server/internal/tenant/app/workspace_service.go +++ b/server/internal/tenant/app/workspace_service.go @@ -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 }) } diff --git a/server/internal/tenant/repository/generated/workspace.sql.go b/server/internal/tenant/repository/generated/workspace.sql.go index 7edb462..7139a66 100644 --- a/server/internal/tenant/repository/generated/workspace.sql.go +++ b/server/internal/tenant/repository/generated/workspace.sql.go @@ -53,6 +53,7 @@ SELECT p.plan_code, p.name AS plan_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 s.end_at > NOW() LIMIT 1 ` diff --git a/server/internal/tenant/repository/image_usage_repo.go b/server/internal/tenant/repository/image_usage_repo.go index 4795c6a..8eb6f4f 100644 --- a/server/internal/tenant/repository/image_usage_repo.go +++ b/server/internal/tenant/repository/image_usage_repo.go @@ -65,6 +65,7 @@ WITH plan AS ( WHERE s.tenant_id = $1 AND s.status = 'active' AND s.deleted_at IS NULL + AND s.end_at > NOW() LIMIT 1 ) UPDATE tenant_image_storage_usage u diff --git a/server/internal/tenant/repository/queries/workspace.sql b/server/internal/tenant/repository/queries/workspace.sql index 4619d7c..94eed1d 100644 --- a/server/internal/tenant/repository/queries/workspace.sql +++ b/server/internal/tenant/repository/queries/workspace.sql @@ -45,6 +45,7 @@ SELECT p.plan_code, p.name AS plan_name, p.quota_policy_json, FROM tenant_plan_subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.status = 'active' AND s.deleted_at IS NULL + AND s.end_at > NOW() LIMIT 1; -- name: ListKolCardsForTenant :many diff --git a/server/internal/tenant/repository/quota_repo.go b/server/internal/tenant/repository/quota_repo.go index d2ec8af..899ceb9 100644 --- a/server/internal/tenant/repository/quota_repo.go +++ b/server/internal/tenant/repository/quota_repo.go @@ -2,11 +2,23 @@ package repository import ( "context" + "errors" "time" + "github.com/jackc/pgx/v5" + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" ) +type ArticleQuotaStatus struct { + PlanCode string + PlanName string + Total int + Used int + Balance int + ResetAt *time.Time +} + type QuotaLedgerInput struct { TenantID int64 OperatorID int64 @@ -30,6 +42,7 @@ type QuotaReservationInput struct { type QuotaRepository interface { GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error) + GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error) InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error) CreateQuotaReservation(ctx context.Context, input QuotaReservationInput) (int64, error) UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error @@ -38,22 +51,90 @@ type QuotaRepository interface { } type quotaRepository struct { - q generated.Querier + q generated.Querier + db generated.DBTX } func NewQuotaRepository(db generated.DBTX) QuotaRepository { - return "aRepository{q: newQuerier(db)} + return "aRepository{q: newQuerier(db), db: db} } func (r *quotaRepository) GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error) { - balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{ - TenantID: tenantID, - QuotaType: quotaType, - }) + if quotaType != "article_generation" { + balance, err := r.q.GetCurrentBalance(ctx, generated.GetCurrentBalanceParams{ + TenantID: tenantID, + QuotaType: quotaType, + }) + if err != nil { + return 0, err + } + return int(balance), nil + } + + status, err := r.GetArticleQuotaStatus(ctx, tenantID, time.Now().UTC()) if err != nil { return 0, err } - return int(balance), nil + return status.Balance, nil +} + +func (r *quotaRepository) GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error) { + access, err := loadTenantPlanAccess(ctx, r.db, tenantID, now) + if err != nil { + return nil, err + } + if access == nil { + return nil, pgx.ErrNoRows + } + + // Non-HTTP callers (scheduler, workers) bypass subscription_guard, so any + // code path that consults article quota must itself enforce that the + // subscription is currently active. Return a zero-balance status when the + // plan is expired/inactive/not-yet-active so the caller short-circuits on + // insufficient balance instead of dispatching generation. + if !access.HasActiveAccess(now) { + return &ArticleQuotaStatus{ + PlanCode: access.PlanCode, + PlanName: access.PlanName, + Total: 0, + Used: 0, + Balance: 0, + }, nil + } + + total := access.ArticleGenerationLimit() + if total <= 0 { + return &ArticleQuotaStatus{ + PlanCode: access.PlanCode, + PlanName: access.PlanName, + Total: 0, + Used: 0, + Balance: 0, + }, nil + } + + windowStart, windowEnd, resetAt := access.ArticleQuotaWindow(now) + if windowEnd.IsZero() { + return nil, errors.New("article quota window end is missing") + } + + used, err := CountEffectiveQuotaReservations(ctx, r.db, tenantID, "article_generation", windowStart, windowEnd) + if err != nil { + return nil, err + } + balance := total - used + if balance < 0 { + balance = 0 + } + + return &ArticleQuotaStatus{ + PlanCode: access.PlanCode, + PlanName: access.PlanName, + Total: total, + Used: used, + Balance: balance, + ResetAt: resetAt, + }, nil } func (r *quotaRepository) InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error) { diff --git a/server/internal/tenant/repository/tenant_plan_repo.go b/server/internal/tenant/repository/tenant_plan_repo.go new file mode 100644 index 0000000..ff69b90 --- /dev/null +++ b/server/internal/tenant/repository/tenant_plan_repo.go @@ -0,0 +1,326 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +const ( + tenantPlanBlockedReasonRequired = "subscription_required" + tenantPlanBlockedReasonExpired = "trial_plan_expired" + tenantPlanBlockedReasonInactive = "subscription_inactive" + defaultArticleQuotaFallback = 0 +) + +type TenantPlanAccess struct { + SubscriptionID int64 + PlanID int64 + PlanCode string + PlanName string + PlanStatus string + SubscriptionStatus string + StartAt time.Time + EndAt time.Time + Policy sharedconfig.PlanQuotaPolicy +} + +func (a *TenantPlanAccess) HasActiveAccess(now time.Time) bool { + if a == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.PlanStatus), "active") { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.SubscriptionStatus), "active") { + return false + } + nowUTC := now.UTC() + if !a.StartAt.IsZero() && a.StartAt.After(nowUTC) { + return false + } + if a.EndAt.IsZero() { + return true + } + return a.EndAt.After(nowUTC) +} + +func (a *TenantPlanAccess) BlockedReason(now time.Time) string { + if a == nil { + return tenantPlanBlockedReasonRequired + } + if a.HasActiveAccess(now) { + return "" + } + if strings.EqualFold(strings.TrimSpace(a.PlanCode), "free") && !a.EndAt.IsZero() && !a.EndAt.After(now.UTC()) { + return tenantPlanBlockedReasonExpired + } + return tenantPlanBlockedReasonInactive +} + +func (a *TenantPlanAccess) ArticleGenerationLimit() int { + if a == nil { + return defaultArticleQuotaFallback + } + return maxInt(a.Policy.ArticleGeneration, 0) +} + +func (a *TenantPlanAccess) BrandLimit() int { + if a == nil { + return 0 + } + return maxInt(a.Policy.BrandLimit, 0) +} + +func (a *TenantPlanAccess) ImageStorageBytes() int64 { + if a == nil { + return 0 + } + return maxInt64(a.Policy.ImageStorageBytes, 0) +} + +func (a *TenantPlanAccess) ArticleQuotaCycle() string { + if a == nil { + return sharedconfig.ArticleQuotaCycleLifetime + } + switch strings.ToLower(strings.TrimSpace(a.Policy.ArticleQuotaCycle)) { + case sharedconfig.ArticleQuotaCycleMonthly: + return sharedconfig.ArticleQuotaCycleMonthly + default: + return sharedconfig.ArticleQuotaCycleLifetime + } +} + +func (a *TenantPlanAccess) ArticleQuotaWindow(now time.Time) (time.Time, time.Time, *time.Time) { + if a == nil { + return time.Time{}, time.Time{}, nil + } + + windowStart := a.StartAt.UTC() + windowEnd := a.EndAt.UTC() + if a.ArticleQuotaCycle() != sharedconfig.ArticleQuotaCycleMonthly { + return windowStart, windowEnd, nil + } + + cycleStart, cycleEnd := monthlyCycleBounds(windowStart, now.UTC()) + if cycleStart.Before(windowStart) { + cycleStart = windowStart + } + if !windowEnd.IsZero() && cycleEnd.After(windowEnd) { + cycleEnd = windowEnd + } + resetAt := cycleEnd + return cycleStart, cycleEnd, &resetAt +} + +type TenantPlanRepository interface { + UpsertConfiguredPlans(ctx context.Context, membership sharedconfig.MembershipConfig) error + GetTenantPlanAccess(ctx context.Context, tenantID int64, now time.Time) (*TenantPlanAccess, error) +} + +type tenantPlanRepository struct { + db generated.DBTX +} + +func NewTenantPlanRepository(db generated.DBTX) TenantPlanRepository { + return &tenantPlanRepository{db: db} +} + +func (r *tenantPlanRepository) UpsertConfiguredPlans(ctx context.Context, membership sharedconfig.MembershipConfig) error { + for _, plan := range membership.Plans { + policyJSON, err := plan.QuotaPolicyJSON() + if err != nil { + return err + } + if _, err := r.db.Exec(ctx, ` +INSERT INTO plans (plan_code, name, quota_policy_json, status) +VALUES ($1, $2, $3::jsonb, 'active') +ON CONFLICT (plan_code) WHERE deleted_at IS NULL +DO UPDATE SET + name = EXCLUDED.name, + quota_policy_json = EXCLUDED.quota_policy_json, + status = 'active', + updated_at = NOW() +`, plan.Code, plan.Name, policyJSON); err != nil { + return err + } + } + return nil +} + +func (r *tenantPlanRepository) GetTenantPlanAccess(ctx context.Context, tenantID int64, now time.Time) (*TenantPlanAccess, error) { + return loadTenantPlanAccess(ctx, r.db, tenantID, now) +} + +func loadTenantPlanAccess(ctx context.Context, db generated.DBTX, tenantID int64, now time.Time) (*TenantPlanAccess, error) { + row := db.QueryRow(ctx, ` +SELECT + s.id, + s.plan_id, + p.plan_code, + p.name, + p.status, + s.status, + s.start_at, + s.end_at, + 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.deleted_at IS NULL + AND p.deleted_at IS NULL +ORDER BY + CASE + WHEN s.status = 'active' AND p.status = 'active' AND s.start_at <= $2 AND s.end_at > $2 THEN 0 + WHEN s.status = 'active' AND s.end_at <= $2 THEN 1 + ELSE 2 + END, + s.start_at DESC, + s.id DESC +LIMIT 1 +`, tenantID, now.UTC()) + + var access TenantPlanAccess + var rawPolicy []byte + if err := row.Scan( + &access.SubscriptionID, + &access.PlanID, + &access.PlanCode, + &access.PlanName, + &access.PlanStatus, + &access.SubscriptionStatus, + &access.StartAt, + &access.EndAt, + &rawPolicy, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + access.StartAt = access.StartAt.UTC() + access.EndAt = access.EndAt.UTC() + access.Policy = sharedconfig.PlanQuotaPolicy{} + if len(rawPolicy) > 0 { + if err := json.Unmarshal(rawPolicy, &access.Policy); err != nil { + return nil, err + } + } + access.Policy.ArticleQuotaCycle = normalizeQuotaCycle(access.Policy.ArticleQuotaCycle, access.PlanCode) + return &access, nil +} + +func CountEffectiveQuotaReservations( + ctx context.Context, + db generated.DBTX, + tenantID int64, + quotaType string, + startAt, endAt time.Time, +) (int, error) { + row := db.QueryRow(ctx, ` +SELECT COALESCE( + SUM( + CASE + WHEN status IN ('pending', 'confirmed') THEN reserved_amount + ELSE 0 + END + ), + 0 +)::INT +FROM quota_reservations +WHERE tenant_id = $1 + AND quota_type = $2 + AND created_at >= $3 + AND created_at < $4 +`, tenantID, quotaType, startAt.UTC(), endAt.UTC()) + + var used int + if err := row.Scan(&used); err != nil { + return 0, err + } + return used, nil +} + +func monthlyCycleBounds(anchor, now time.Time) (time.Time, time.Time) { + start := anchor.UTC() + current := now.UTC() + if current.Before(start) { + return start, addMonthsPreserveAnchor(start, 1) + } + + months := (current.Year()-start.Year())*12 + int(current.Month()-start.Month()) + cycleStart := addMonthsPreserveAnchor(start, months) + for cycleStart.After(current) { + months-- + cycleStart = addMonthsPreserveAnchor(start, months) + } + + cycleEnd := addMonthsPreserveAnchor(start, months+1) + for !cycleEnd.After(current) { + months++ + cycleStart = addMonthsPreserveAnchor(start, months) + cycleEnd = addMonthsPreserveAnchor(start, months+1) + } + + return cycleStart, cycleEnd +} + +// addMonthsPreserveAnchor advances anchor by n months while clamping the +// resulting day to the target month's last day. This avoids Go's time.AddDate +// drift where e.g. Jan 31 + 1 month becomes Mar 3 instead of Feb 28. +func addMonthsPreserveAnchor(anchor time.Time, n int) time.Time { + if n == 0 { + return anchor + } + y := anchor.Year() + m := int(anchor.Month()) + n + for m > 12 { + y++ + m -= 12 + } + for m < 1 { + y-- + m += 12 + } + // Last day of the target month: day 0 of the following month. + lastDay := time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, anchor.Location()).Day() + day := anchor.Day() + if day > lastDay { + day = lastDay + } + return time.Date(y, time.Month(m), day, anchor.Hour(), anchor.Minute(), anchor.Second(), anchor.Nanosecond(), anchor.Location()) +} + +func normalizeQuotaCycle(value, planCode string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case sharedconfig.ArticleQuotaCycleMonthly: + return sharedconfig.ArticleQuotaCycleMonthly + default: + if strings.EqualFold(strings.TrimSpace(planCode), "free") { + return sharedconfig.ArticleQuotaCycleLifetime + } + return sharedconfig.ArticleQuotaCycleMonthly + } +} + +func maxInt(value, fallback int) int { + if value < fallback { + return fallback + } + return value +} + +func maxInt64(value, fallback int64) int64 { + if value < fallback { + return fallback + } + return value +} diff --git a/server/internal/tenant/repository/tenant_plan_repo_test.go b/server/internal/tenant/repository/tenant_plan_repo_test.go new file mode 100644 index 0000000..af7e85f --- /dev/null +++ b/server/internal/tenant/repository/tenant_plan_repo_test.go @@ -0,0 +1,263 @@ +package repository + +import ( + "testing" + "time" + + sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" +) + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time %q: %v", value, err) + } + return parsed.UTC() +} + +func TestHasActiveAccess(t *testing.T) { + now := mustParseTime(t, "2026-04-18T12:00:00Z") + + cases := []struct { + name string + access *TenantPlanAccess + want bool + }{ + {name: "nil access", access: nil, want: false}, + { + name: "plan inactive", + access: &TenantPlanAccess{ + PlanStatus: "inactive", + SubscriptionStatus: "active", + StartAt: now.Add(-time.Hour), + EndAt: now.Add(time.Hour), + }, + want: false, + }, + { + name: "subscription inactive", + access: &TenantPlanAccess{ + PlanStatus: "active", + SubscriptionStatus: "cancelled", + StartAt: now.Add(-time.Hour), + EndAt: now.Add(time.Hour), + }, + want: false, + }, + { + name: "future start_at is blocked", + access: &TenantPlanAccess{ + PlanStatus: "active", + SubscriptionStatus: "active", + StartAt: now.Add(time.Hour), + EndAt: now.Add(24 * time.Hour), + }, + want: false, + }, + { + name: "expired end_at", + access: &TenantPlanAccess{ + PlanStatus: "active", + SubscriptionStatus: "active", + StartAt: now.Add(-48 * time.Hour), + EndAt: now.Add(-time.Hour), + }, + want: false, + }, + { + name: "within window", + access: &TenantPlanAccess{ + PlanStatus: "active", + SubscriptionStatus: "active", + StartAt: now.Add(-time.Hour), + EndAt: now.Add(time.Hour), + }, + want: true, + }, + { + name: "zero end_at is perpetual", + access: &TenantPlanAccess{ + PlanStatus: "active", + SubscriptionStatus: "active", + StartAt: now.Add(-time.Hour), + }, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.access.HasActiveAccess(now); got != tc.want { + t.Fatalf("HasActiveAccess = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBlockedReason(t *testing.T) { + now := mustParseTime(t, "2026-04-18T12:00:00Z") + + cases := []struct { + name string + access *TenantPlanAccess + want string + }{ + {name: "nil -> required", access: nil, want: "subscription_required"}, + { + name: "active access -> empty", + access: &TenantPlanAccess{ + PlanStatus: "active", SubscriptionStatus: "active", + StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), + }, + want: "", + }, + { + name: "free expired -> trial_plan_expired", + access: &TenantPlanAccess{ + PlanCode: "free", PlanStatus: "active", SubscriptionStatus: "active", + StartAt: now.Add(-96 * time.Hour), EndAt: now.Add(-time.Hour), + }, + want: "trial_plan_expired", + }, + { + name: "paid expired -> inactive", + access: &TenantPlanAccess{ + PlanCode: "pro", PlanStatus: "active", SubscriptionStatus: "active", + StartAt: now.Add(-30 * 24 * time.Hour), EndAt: now.Add(-time.Hour), + }, + want: "subscription_inactive", + }, + { + name: "future start_at -> inactive (not expired)", + access: &TenantPlanAccess{ + PlanCode: "pro", PlanStatus: "active", SubscriptionStatus: "active", + StartAt: now.Add(time.Hour), EndAt: now.Add(48 * time.Hour), + }, + want: "subscription_inactive", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.access.BlockedReason(now); got != tc.want { + t.Fatalf("BlockedReason = %q, want %q", got, tc.want) + } + }) + } +} + +func TestAddMonthsPreserveAnchor(t *testing.T) { + cases := []struct { + name string + anchor string + n int + want string + }{ + {name: "jan 31 + 1 month clamps to feb 28 (non-leap)", anchor: "2026-01-31T09:00:00Z", n: 1, want: "2026-02-28T09:00:00Z"}, + {name: "jan 31 + 1 month clamps to feb 29 (leap)", anchor: "2024-01-31T09:00:00Z", n: 1, want: "2024-02-29T09:00:00Z"}, + {name: "jan 31 + 2 months -> mar 31", anchor: "2026-01-31T09:00:00Z", n: 2, want: "2026-03-31T09:00:00Z"}, + {name: "jan 30 + 1 month clamps to feb 28", anchor: "2026-01-30T09:00:00Z", n: 1, want: "2026-02-28T09:00:00Z"}, + {name: "mar 31 + 1 month clamps to apr 30", anchor: "2026-03-31T09:00:00Z", n: 1, want: "2026-04-30T09:00:00Z"}, + {name: "cross year", anchor: "2026-12-15T00:00:00Z", n: 2, want: "2027-02-15T00:00:00Z"}, + {name: "n=0 returns anchor", anchor: "2026-04-18T12:00:00Z", n: 0, want: "2026-04-18T12:00:00Z"}, + {name: "negative step backwards", anchor: "2026-03-31T09:00:00Z", n: -1, want: "2026-02-28T09:00:00Z"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + anchor := mustParseTime(t, tc.anchor) + want := mustParseTime(t, tc.want) + got := addMonthsPreserveAnchor(anchor, tc.n) + if !got.Equal(want) { + t.Fatalf("addMonthsPreserveAnchor(%s, %d) = %s, want %s", tc.anchor, tc.n, got.Format(time.RFC3339), want.Format(time.RFC3339)) + } + }) + } +} + +func TestMonthlyCycleBoundsMonthEndAnchor(t *testing.T) { + // Subscription anchored on Jan 31; current time inside Feb. Legacy + // AddDate-based implementation drifted into March (Feb 31 -> Mar 3). + anchor := mustParseTime(t, "2026-01-31T09:00:00Z") + now := mustParseTime(t, "2026-02-15T00:00:00Z") + + start, end := monthlyCycleBounds(anchor, now) + + wantStart := mustParseTime(t, "2026-01-31T09:00:00Z") + wantEnd := mustParseTime(t, "2026-02-28T09:00:00Z") + + if !start.Equal(wantStart) { + t.Fatalf("cycle start = %s, want %s", start.Format(time.RFC3339), wantStart.Format(time.RFC3339)) + } + if !end.Equal(wantEnd) { + t.Fatalf("cycle end = %s, want %s", end.Format(time.RFC3339), wantEnd.Format(time.RFC3339)) + } +} + +func TestMonthlyCycleBoundsContainsNowAcrossLeap(t *testing.T) { + anchor := mustParseTime(t, "2024-01-31T00:00:00Z") + + cases := []struct { + name string + now string + wantStart string + wantEnd string + }{ + {name: "late jan", now: "2024-01-31T12:00:00Z", wantStart: "2024-01-31T00:00:00Z", wantEnd: "2024-02-29T00:00:00Z"}, + {name: "mid feb leap", now: "2024-02-15T00:00:00Z", wantStart: "2024-01-31T00:00:00Z", wantEnd: "2024-02-29T00:00:00Z"}, + {name: "early mar", now: "2024-03-01T00:00:00Z", wantStart: "2024-02-29T00:00:00Z", wantEnd: "2024-03-31T00:00:00Z"}, + {name: "mid mar", now: "2024-03-15T00:00:00Z", wantStart: "2024-02-29T00:00:00Z", wantEnd: "2024-03-31T00:00:00Z"}, + {name: "early apr clamps to 30", now: "2024-04-15T00:00:00Z", wantStart: "2024-03-31T00:00:00Z", wantEnd: "2024-04-30T00:00:00Z"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + start, end := monthlyCycleBounds(anchor, mustParseTime(t, tc.now)) + if !start.Equal(mustParseTime(t, tc.wantStart)) { + t.Fatalf("cycle start = %s, want %s", start.Format(time.RFC3339), tc.wantStart) + } + if !end.Equal(mustParseTime(t, tc.wantEnd)) { + t.Fatalf("cycle end = %s, want %s", end.Format(time.RFC3339), tc.wantEnd) + } + // Invariant: now must fall within [start, end) + now := mustParseTime(t, tc.now) + if now.Before(start) || !now.Before(end) { + t.Fatalf("now %s not within [%s, %s)", tc.now, start.Format(time.RFC3339), end.Format(time.RFC3339)) + } + }) + } +} + +func TestArticleQuotaWindowMonthlyRespectsSubscriptionBounds(t *testing.T) { + anchor := mustParseTime(t, "2026-01-31T00:00:00Z") + end := mustParseTime(t, "2027-01-31T00:00:00Z") + access := &TenantPlanAccess{ + PlanCode: "pro", + PlanStatus: "active", + SubscriptionStatus: "active", + StartAt: anchor, + EndAt: end, + Policy: sharedconfig.PlanQuotaPolicy{ + ArticleGeneration: 400, + ArticleQuotaCycle: sharedconfig.ArticleQuotaCycleMonthly, + BrandLimit: 2, + ImageStorageBytes: 1024 * 1024 * 1024, + }, + } + + now := mustParseTime(t, "2026-02-15T00:00:00Z") + start, cycleEnd, reset := access.ArticleQuotaWindow(now) + + wantStart := mustParseTime(t, "2026-01-31T00:00:00Z") + wantEnd := mustParseTime(t, "2026-02-28T00:00:00Z") + if !start.Equal(wantStart) { + t.Fatalf("window start = %s, want %s", start.Format(time.RFC3339), wantStart.Format(time.RFC3339)) + } + if !cycleEnd.Equal(wantEnd) { + t.Fatalf("window end = %s, want %s", cycleEnd.Format(time.RFC3339), wantEnd.Format(time.RFC3339)) + } + if reset == nil || !reset.Equal(wantEnd) { + t.Fatalf("reset = %v, want %s", reset, wantEnd.Format(time.RFC3339)) + } +} diff --git a/server/internal/tenant/transport/auth_handler.go b/server/internal/tenant/transport/auth_handler.go index 57c91c1..3b85dd4 100644 --- a/server/internal/tenant/transport/auth_handler.go +++ b/server/internal/tenant/transport/auth_handler.go @@ -20,6 +20,7 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler { return &AuthHandler{ svc: app.NewAuthService( repository.NewAuthRepository(a.DB), + repository.NewTenantPlanRepository(a.DB), a.KolProfiles, a.JWT, a.Sessions, diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 977fc75..48af8db 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -4,6 +4,7 @@ import ( "github.com/geo-platform/tenant-api/internal/bootstrap" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/tenant/repository" ) func RegisterRoutes(app *bootstrap.App) { @@ -35,7 +36,10 @@ func RegisterRoutes(app *bootstrap.App) { protected.GET("/auth/me", authHandler.Me) protected.POST("/auth/logout", authHandler.Logout) - workspace := protected.Group("/tenant/workspace") + tenantProtected := protected.Group("/tenant") + tenantProtected.Use(RequireActiveTenantSubscription(repository.NewTenantPlanRepository(app.DB))) + + workspace := tenantProtected.Group("/workspace") wsHandler := NewWorkspaceHandler(app) workspace.GET("/overview", wsHandler.Overview) workspace.GET("/recent-articles", wsHandler.RecentArticles) @@ -43,7 +47,7 @@ func RegisterRoutes(app *bootstrap.App) { workspace.GET("/template-cards", wsHandler.TemplateCards) workspace.GET("/kol-cards", wsHandler.KolCards) - templates := protected.Group("/tenant/templates") + templates := tenantProtected.Group("/templates") tplHandler := NewTemplateHandler(app) templates.GET("", tplHandler.List) templates.GET("/:id", tplHandler.Detail) @@ -56,7 +60,7 @@ func RegisterRoutes(app *bootstrap.App) { templates.GET("/:id/gen_outline_task_result", tplHandler.GetOutlineTaskResult) templates.POST("/:id/generate", tplHandler.Generate) - kolManage := protected.Group("/tenant/kol/manage") + kolManage := tenantProtected.Group("/kol/manage") kolManageHandler := NewKolManageHandler(app, publicAssets) kolManage.GET("/profile", kolManageHandler.GetProfile) kolManage.PUT("/profile", kolManageHandler.UpdateProfile) @@ -80,19 +84,19 @@ func RegisterRoutes(app *bootstrap.App) { kolManage.POST("/assist/stream", kolManageHandler.AssistStream) kolManage.GET("/assist/:id", kolManageHandler.AssistGet) - kolDash := protected.Group("/tenant/kol/dashboard") + kolDash := tenantProtected.Group("/kol/dashboard") kolDashboardHandler := NewKolDashboardHandler(app) kolDash.GET("/overview", kolDashboardHandler.Overview) kolDash.GET("/packages", kolDashboardHandler.Packages) kolDash.GET("/trend", kolDashboardHandler.Trend) - kolMarketplace := protected.Group("/tenant/kol/marketplace") + kolMarketplace := tenantProtected.Group("/kol/marketplace") kolMarketplaceHandler := NewKolMarketplaceHandler(app) kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages) kolMarketplace.GET("/packages/:id", kolMarketplaceHandler.GetPackage) kolMarketplace.POST("/packages/:id/subscribe", kolMarketplaceHandler.Subscribe) - kolSubscriptions := protected.Group("/tenant/kol") + kolSubscriptions := tenantProtected.Group("/kol") kolSubscriptions.GET("/subscriptions", kolMarketplaceHandler.ListSubscriptions) kolSubscriptions.GET("/subscription-prompts", kolMarketplaceHandler.ListSubscriptionPrompts) kolSubscriptions.GET("/subscription-prompts/:id/schema", kolMarketplaceHandler.SubscriptionPromptSchema) @@ -100,13 +104,13 @@ func RegisterRoutes(app *bootstrap.App) { // V1 compromise: these admin operations live on tenant-api and are gated by // tenant_role=tenant_admin until the platform-api/module is built. - kolAdmin := protected.Group("/tenant/kol/admin") + kolAdmin := tenantProtected.Group("/kol/admin") kolAdminHandler := NewKolAdminHandler(app) kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription) kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind) kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription) - articles := protected.Group("/tenant/articles") + articles := tenantProtected.Group("/articles") artHandler := NewArticleHandler(app) articles.GET("", artHandler.List) articles.POST("", artHandler.Create) @@ -121,14 +125,14 @@ func RegisterRoutes(app *bootstrap.App) { articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch) articles.DELETE("/:id", artHandler.Delete) - media := protected.Group("/tenant/media") + media := tenantProtected.Group("/media") media.GET("/platforms", pluginHandler.ListPlatforms) media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts) media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation) media.POST("/plugin-sessions", pluginHandler.CreatePluginSession) media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount) - brands := protected.Group("/tenant/brands") + brands := tenantProtected.Group("/brands") brandHandler := NewBrandHandler(app) brands.GET("", brandHandler.List) brands.GET("/library-summary", brandHandler.Summary) @@ -149,13 +153,13 @@ func RegisterRoutes(app *bootstrap.App) { brands.PUT("/:id/competitors/:cid", brandHandler.UpdateCompetitor) brands.DELETE("/:id/competitors/:cid", brandHandler.DeleteCompetitor) - monitoring := protected.Group("/tenant/monitoring") + monitoring := tenantProtected.Group("/monitoring") monitoringHandler := NewMonitoringHandler(app) monitoring.GET("/dashboard/composite", monitoringHandler.DashboardComposite) monitoring.GET("/brands/:brand_id/questions/:question_id/detail", monitoringHandler.QuestionDetail) monitoring.POST("/brands/:brand_id/collect-now", monitoringHandler.CollectNow) - knowledge := protected.Group("/tenant/knowledge") + knowledge := tenantProtected.Group("/knowledge") knowledgeHandler := NewKnowledgeHandler(app) knowledge.GET("/groups", knowledgeHandler.ListGroups) knowledge.POST("/groups", knowledgeHandler.CreateGroup) @@ -169,7 +173,7 @@ func RegisterRoutes(app *bootstrap.App) { knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem) // Prompt Rules - promptRules := protected.Group("/tenant/prompt-rules") + promptRules := tenantProtected.Group("/prompt-rules") prHandler := NewPromptRuleHandler(app) promptRules.GET("", prHandler.List) promptRules.GET("/simple", prHandler.ListSimple) @@ -185,7 +189,7 @@ func RegisterRoutes(app *bootstrap.App) { promptRules.DELETE("/groups/:gid", prHandler.DeleteGroup) // Schedule Tasks - schedules := protected.Group("/tenant/schedules") + schedules := tenantProtected.Group("/schedules") schHandler := NewScheduleTaskHandler(app) schedules.GET("", schHandler.List) schedules.POST("", schHandler.Create) @@ -194,11 +198,11 @@ func RegisterRoutes(app *bootstrap.App) { schedules.DELETE("/:id", schHandler.Delete) schedules.PUT("/:id/status", schHandler.ToggleStatus) - instantTasks := protected.Group("/tenant/instant-tasks") + instantTasks := tenantProtected.Group("/instant-tasks") instHandler := NewInstantTaskHandler(app) instantTasks.GET("", instHandler.List) - images := protected.Group("/tenant/images") + images := tenantProtected.Group("/images") imgHandler := NewImageHandler(app) images.GET("", imgHandler.ListImages) images.POST("", imgHandler.UploadImage) @@ -207,7 +211,7 @@ func RegisterRoutes(app *bootstrap.App) { images.DELETE("/:id", imgHandler.DeleteImage) images.GET("/storage-usage", imgHandler.StorageUsage) - imageFolders := protected.Group("/tenant/images/folders") + imageFolders := tenantProtected.Group("/images/folders") imageFolders.GET("", imgHandler.ListFolders) imageFolders.POST("", imgHandler.CreateFolder) imageFolders.PUT("/:id", imgHandler.UpdateFolder) diff --git a/server/internal/tenant/transport/subscription_guard.go b/server/internal/tenant/transport/subscription_guard.go new file mode 100644 index 0000000..22320b1 --- /dev/null +++ b/server/internal/tenant/transport/subscription_guard.go @@ -0,0 +1,63 @@ +package transport + +import ( + "time" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +func RequireActiveTenantSubscription(plans repository.TenantPlanRepository) gin.HandlerFunc { + return func(c *gin.Context) { + actor, ok := auth.ActorFromCtx(c.Request.Context()) + if !ok { + response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated")) + c.Abort() + return + } + + now := time.Now().UTC() + access, err := plans.GetTenantPlanAccess(c.Request.Context(), actor.TenantID, now) + if err != nil { + response.Error(c, response.ErrInternal(50091, "subscription_lookup_failed", "failed to load tenant subscription")) + c.Abort() + return + } + if access != nil && access.HasActiveAccess(now) { + c.Next() + return + } + + reason := "" + if access != nil { + reason = access.BlockedReason(now) + } else { + reason = "subscription_required" + } + + switch reason { + case "trial_plan_expired": + response.Error(c, response.ErrForbidden( + 40382, + "trial_plan_expired", + "free trial has expired, please contact administrator to reactivate access", + )) + case "subscription_required": + response.Error(c, response.ErrForbidden( + 40381, + "subscription_required", + "subscription is required, please contact administrator", + )) + default: + response.Error(c, response.ErrForbidden( + 40383, + "subscription_inactive", + "subscription is inactive, please contact administrator", + )) + } + c.Abort() + } +} diff --git a/server/internal/tenant/transport/workspace_handler.go b/server/internal/tenant/transport/workspace_handler.go index 8f195ad..6ad75f6 100644 --- a/server/internal/tenant/transport/workspace_handler.go +++ b/server/internal/tenant/transport/workspace_handler.go @@ -21,6 +21,7 @@ func NewWorkspaceHandler(a *bootstrap.App) *WorkspaceHandler { repository.NewTemplateRepository(a.DB), a.Cache, ), + repository.NewQuotaRepository(a.DB), ).WithCache(a.Cache), } }