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:
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user