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:
2026-04-18 20:56:05 +08:00
parent 9ec9314aea
commit 63667ed2d1
28 changed files with 1738 additions and 80 deletions
+44 -26
View File
@@ -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)
}