diff --git a/deploy/config.yaml b/deploy/config.yaml index d39a998..59b61f7 100644 --- a/deploy/config.yaml +++ b/deploy/config.yaml @@ -48,6 +48,8 @@ membership: name: Free article_generation: 3 article_quota_cycle: lifetime + ai_points_monthly: 0 + ai_point_base_chars: 1000 image_storage_bytes: 10485760 company_limit: 1 subscription_duration: 72h @@ -56,6 +58,8 @@ membership: name: Plus article_generation: 200 article_quota_cycle: monthly + ai_points_monthly: 0 + ai_point_base_chars: 1000 image_storage_bytes: 524288000 company_limit: 1 subscription_duration: 720h @@ -63,6 +67,8 @@ membership: name: Pro article_generation: 400 article_quota_cycle: monthly + ai_points_monthly: 1500 + ai_point_base_chars: 1000 image_storage_bytes: 1073741824 company_limit: 2 subscription_duration: 720h diff --git a/server/configs/config.local.yaml.example b/server/configs/config.local.yaml.example index d5dda19..1c73c19 100644 --- a/server/configs/config.local.yaml.example +++ b/server/configs/config.local.yaml.example @@ -72,6 +72,8 @@ membership: name: Free article_generation: 3 article_quota_cycle: lifetime + ai_points_monthly: 0 + ai_point_base_chars: 1000 image_storage_bytes: 10485760 company_limit: 1 subscription_duration: 72h @@ -80,6 +82,8 @@ membership: name: Plus article_generation: 200 article_quota_cycle: monthly + ai_points_monthly: 0 + ai_point_base_chars: 1000 image_storage_bytes: 524288000 company_limit: 1 subscription_duration: 720h @@ -87,6 +91,8 @@ membership: name: Pro article_generation: 400 article_quota_cycle: monthly + ai_points_monthly: 1500 + ai_point_base_chars: 1000 image_storage_bytes: 1073741824 company_limit: 2 subscription_duration: 720h diff --git a/server/configs/config.yaml b/server/configs/config.yaml index 2017a29..bf83df8 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -92,6 +92,8 @@ membership: name: Free article_generation: 3 article_quota_cycle: lifetime + ai_points_monthly: 0 + ai_point_base_chars: 1000 image_storage_bytes: 10485760 company_limit: 1 subscription_duration: 72h @@ -100,6 +102,8 @@ membership: name: Plus article_generation: 200 article_quota_cycle: monthly + ai_points_monthly: 0 + ai_point_base_chars: 1000 image_storage_bytes: 524288000 company_limit: 1 subscription_duration: 720h @@ -107,6 +111,8 @@ membership: name: Pro article_generation: 400 article_quota_cycle: monthly + ai_points_monthly: 1500 + ai_point_base_chars: 1000 image_storage_bytes: 1073741824 company_limit: 2 subscription_duration: 720h diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 7642c45..f7a3e0a 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -145,6 +145,8 @@ type MembershipPlanConfig struct { Name string `mapstructure:"name"` ArticleGeneration int `mapstructure:"article_generation"` ArticleQuotaCycle string `mapstructure:"article_quota_cycle"` + AIPointsMonthly int `mapstructure:"ai_points_monthly"` + AIPointBaseChars int `mapstructure:"ai_point_base_chars"` ImageStorageBytes int64 `mapstructure:"image_storage_bytes"` CompanyLimit int `mapstructure:"company_limit"` SubscriptionDuration time.Duration `mapstructure:"subscription_duration"` @@ -154,6 +156,8 @@ type MembershipPlanConfig struct { type PlanQuotaPolicy struct { ArticleGeneration int `json:"article_generation"` ArticleQuotaCycle string `json:"article_quota_cycle"` + AIPointsMonthly int `json:"ai_points_monthly"` + AIPointBaseChars int `json:"ai_point_base_chars"` ImageStorageBytes int64 `json:"image_storage_bytes"` BrandLimit int `json:"brand_limit"` SubscriptionDurationSecs int64 `json:"subscription_duration_seconds,omitempty"` @@ -174,6 +178,8 @@ func (p MembershipPlanConfig) QuotaPolicy() PlanQuotaPolicy { policy := PlanQuotaPolicy{ ArticleGeneration: maxInt(p.ArticleGeneration, 0), ArticleQuotaCycle: normalizeArticleQuotaCycle(p.ArticleQuotaCycle, p.Code), + AIPointsMonthly: maxInt(p.AIPointsMonthly, 0), + AIPointBaseChars: normalizeAIPointBaseChars(p.AIPointBaseChars), ImageStorageBytes: maxInt64(p.ImageStorageBytes, 0), BrandLimit: maxInt(p.CompanyLimit, 0), ContactAdminOnExpiry: p.ContactAdminOnExpiry, @@ -695,6 +701,10 @@ func normalizeMembershipConfig(cfg *MembershipConfig) { if plan.ArticleGeneration < 0 { plan.ArticleGeneration = 0 } + if plan.AIPointsMonthly < 0 { + plan.AIPointsMonthly = 0 + } + plan.AIPointBaseChars = normalizeAIPointBaseChars(plan.AIPointBaseChars) if plan.ImageStorageBytes < 0 { plan.ImageStorageBytes = 0 } @@ -725,6 +735,7 @@ func defaultMembershipPlans() []MembershipPlanConfig { Name: "Free", ArticleGeneration: 3, ArticleQuotaCycle: ArticleQuotaCycleLifetime, + AIPointBaseChars: 1000, ImageStorageBytes: 10 * 1024 * 1024, CompanyLimit: 1, SubscriptionDuration: 72 * time.Hour, @@ -735,6 +746,7 @@ func defaultMembershipPlans() []MembershipPlanConfig { Name: "Plus", ArticleGeneration: 200, ArticleQuotaCycle: ArticleQuotaCycleMonthly, + AIPointBaseChars: 1000, ImageStorageBytes: 500 * 1024 * 1024, CompanyLimit: 1, SubscriptionDuration: 720 * time.Hour, @@ -744,6 +756,8 @@ func defaultMembershipPlans() []MembershipPlanConfig { Name: "Pro", ArticleGeneration: 400, ArticleQuotaCycle: ArticleQuotaCycleMonthly, + AIPointsMonthly: 1500, + AIPointBaseChars: 1000, ImageStorageBytes: 1024 * 1024 * 1024, CompanyLimit: 2, SubscriptionDuration: 720 * time.Hour, @@ -751,6 +765,13 @@ func defaultMembershipPlans() []MembershipPlanConfig { } } +func normalizeAIPointBaseChars(value int) int { + if value <= 0 { + return 1000 + } + return value +} + func normalizeArticleQuotaCycle(value, planCode string) string { switch strings.ToLower(strings.TrimSpace(value)) { case ArticleQuotaCycleLifetime: diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index 71f36aa..fb1d56b 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -237,6 +237,12 @@ membership: {} if pro.ArticleGeneration != 400 { t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration) } + if pro.AIPointsMonthly != 1500 { + t.Fatalf("expected pro ai points 1500, got %d", pro.AIPointsMonthly) + } + if pro.AIPointBaseChars != 1000 { + t.Fatalf("expected pro ai point base chars 1000, got %d", pro.AIPointBaseChars) + } if pro.CompanyLimit != 2 { t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit) } diff --git a/server/internal/tenant/app/auth_service.go b/server/internal/tenant/app/auth_service.go index 542b364..3e888e4 100644 --- a/server/internal/tenant/app/auth_service.go +++ b/server/internal/tenant/app/auth_service.go @@ -71,6 +71,8 @@ type MembershipInfo struct { EndAt *string `json:"end_at"` ArticleGenerationLimit int `json:"article_generation_limit"` ArticleQuotaCycle string `json:"article_quota_cycle"` + AIPointsMonthly int `json:"ai_points_monthly"` + AIPointBaseChars int `json:"ai_point_base_chars"` CompanyLimit int `json:"company_limit"` ImageStorageBytes int64 `json:"image_storage_bytes"` ContactAdminOnExpiry bool `json:"contact_admin_on_expiry"` @@ -271,6 +273,8 @@ func (s *AuthService) loadMembershipInfo(ctx context.Context, tenantID int64) (* EndAt: formatTimeValuePtr(access.EndAt), ArticleGenerationLimit: access.ArticleGenerationLimit(), ArticleQuotaCycle: access.ArticleQuotaCycle(), + AIPointsMonthly: access.AIPointsMonthlyLimit(), + AIPointBaseChars: access.AIPointBaseChars(), CompanyLimit: access.BrandLimit(), ImageStorageBytes: access.ImageStorageBytes(), ContactAdminOnExpiry: access.Policy.ContactAdminOnExpiry, diff --git a/server/internal/tenant/repository/tenant_plan_repo.go b/server/internal/tenant/repository/tenant_plan_repo.go index ff69b90..7dfcd7b 100644 --- a/server/internal/tenant/repository/tenant_plan_repo.go +++ b/server/internal/tenant/repository/tenant_plan_repo.go @@ -98,6 +98,38 @@ func (a *TenantPlanAccess) ArticleQuotaCycle() string { } } +func (a *TenantPlanAccess) AIPointsMonthlyLimit() int { + if a == nil { + return 0 + } + return maxInt(a.Policy.AIPointsMonthly, 0) +} + +func (a *TenantPlanAccess) AIPointBaseChars() int { + if a == nil || a.Policy.AIPointBaseChars <= 0 { + return 1000 + } + return a.Policy.AIPointBaseChars +} + +func (a *TenantPlanAccess) AIPointsWindow(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() + 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 +} + func (a *TenantPlanAccess) ArticleQuotaWindow(now time.Time) (time.Time, time.Time, *time.Time) { if a == nil { return time.Time{}, time.Time{}, nil