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
@@ -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 &quotaRepository{q: newQuerier(db)}
return &quotaRepository{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) {