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