mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
337 lines
11 KiB
Go
337 lines
11 KiB
Go
package feature
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
const (
|
|
// FreeTierDailyEmailLimit is the daily campaign email limit for free trial users
|
|
FreeTierDailyEmailLimit = 20
|
|
|
|
// UnlimitedEmails indicates unlimited daily emails
|
|
UnlimitedEmails = -1
|
|
|
|
// Attachment storage quotas (overall bytes per organization). Generous by
|
|
// design; paid orgs get a much larger pool. Tunable per-plan later via a
|
|
// plans column without changing callers.
|
|
FreeTierStorageBytes int64 = 2 << 30 // 2 GiB for free / trial orgs
|
|
PaidStorageBytes int64 = 50 << 30 // 50 GiB for paid orgs
|
|
)
|
|
|
|
type FeatureGateService interface {
|
|
// CanSendCampaignEmail checks if an organization can send campaign emails
|
|
CanSendCampaignEmail(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
|
|
|
// CanUseWarmup checks if an organization can use the warmup feature.
|
|
// Free-trial orgs may use warmup during their 14-day window.
|
|
CanUseWarmup(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
|
|
|
// CanUseUnibox checks if an organization can use the unibox feature.
|
|
// Free-trial orgs may use unibox during their 14-day window.
|
|
CanUseUnibox(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
|
|
|
// CanAddInbox returns whether the org may connect another email account.
|
|
// Free-trial orgs are capped at FreeTrialInboxLimit connected inboxes.
|
|
// Paid orgs are not gated here (plan limits govern sending volume).
|
|
CanAddInbox(ctx context.Context, orgID uuid.UUID, currentCount int) (bool, *errx.Error)
|
|
|
|
// GetDailyEmailLimit returns the daily email limit for an organization
|
|
// Returns -1 for unlimited, 0 for blocked
|
|
GetDailyEmailLimit(ctx context.Context, orgID uuid.UUID) (int, *errx.Error)
|
|
|
|
// GetSubscriptionStatus returns subscription info for feature checks
|
|
GetSubscriptionStatus(ctx context.Context, orgID uuid.UUID) (*SubscriptionStatus, *errx.Error)
|
|
|
|
// IsPaidOrganization checks if the organization has an active paid subscription
|
|
IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
|
|
|
// GetStorageLimitBytes returns the org's total attachment storage quota in
|
|
// bytes (generous; larger for paid orgs).
|
|
GetStorageLimitBytes(ctx context.Context, orgID uuid.UUID) (int64, *errx.Error)
|
|
|
|
// CanUseWritingAssistant reports whether the org may use the AI writing
|
|
// assistant (paid or in free trial; expired/no-subscription is blocked).
|
|
CanUseWritingAssistant(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
|
|
|
// CanUseInboxAgent reports whether the org may use the inbox agent (paid
|
|
// only — it drafts unattended, so it is not offered on the free trial). The
|
|
// org must ALSO have opted in (organizations.inbox_agent_enabled), which is
|
|
// checked separately by the caller.
|
|
CanUseInboxAgent(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
|
}
|
|
|
|
// SubscriptionStatus contains status info for feature gating
|
|
type SubscriptionStatus struct {
|
|
HasSubscription bool `json:"has_subscription"`
|
|
IsInFreeTrial bool `json:"is_in_free_trial"`
|
|
IsFreeTrialExpired bool `json:"is_free_trial_expired"`
|
|
IsPaidSubscriber bool `json:"is_paid_subscriber"`
|
|
DailyEmailLimit int `json:"daily_email_limit"`
|
|
Plan *models.Plan `json:"plan,omitempty"`
|
|
}
|
|
|
|
type featureGateService struct {
|
|
subRepo repository.SubscriptionRepository
|
|
planRepo repository.PlanRepository
|
|
// selfHost unlocks every gate. Set when BILLING_PROVIDER=none (the self-host
|
|
// default): there is no payment provider, so every feature is available and
|
|
// sending is unlimited. Stripe deployments (BILLING_PROVIDER=stripe) keep the
|
|
// subscription-based gating below.
|
|
selfHost bool
|
|
}
|
|
|
|
func NewService(subRepo repository.SubscriptionRepository, planRepo repository.PlanRepository) FeatureGateService {
|
|
return &featureGateService{
|
|
subRepo: subRepo,
|
|
planRepo: planRepo,
|
|
selfHost: config.BillingProvider() == "none",
|
|
}
|
|
}
|
|
|
|
// CanSendCampaignEmail checks if an organization can send campaign emails
|
|
func (s *featureGateService) CanSendCampaignEmail(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
|
|
// No subscription = blocked
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
|
|
// Active paid subscription = allowed
|
|
if sub.HasPaidSubscription() {
|
|
return true, nil
|
|
}
|
|
|
|
// In free trial = allowed (with limit)
|
|
if sub.IsInFreeTrial() {
|
|
return true, nil
|
|
}
|
|
|
|
// Trial expired, no paid subscription = blocked
|
|
return false, nil
|
|
}
|
|
|
|
// CanUseWarmup checks if an organization can use the warmup feature.
|
|
// Free-trial users get warmup access for the 14-day window via the
|
|
// `free` pool; once the trial expires they must upgrade.
|
|
func (s *featureGateService) CanUseWarmup(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
return sub.CanUseWarmup(), nil
|
|
}
|
|
|
|
// CanUseUnibox checks if an organization can use the unibox feature.
|
|
// Same trial allowance as warmup.
|
|
func (s *featureGateService) CanUseUnibox(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
return sub.CanUseUnibox(), nil
|
|
}
|
|
|
|
// CanAddInbox enforces the free-trial inbox cap. Paid orgs are never
|
|
// blocked here. Trial orgs may connect up to models.FreeTrialInboxLimit
|
|
// inboxes; once that cap is reached we refuse so the warmup pool is not
|
|
// seeded with throwaway trial accounts.
|
|
func (s *featureGateService) CanAddInbox(ctx context.Context, orgID uuid.UUID, currentCount int) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
if sub.HasPaidSubscription() {
|
|
return true, nil
|
|
}
|
|
if sub.IsInFreeTrial() {
|
|
return currentCount < models.FreeTrialInboxLimit, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// GetDailyEmailLimit returns the daily email limit for an organization
|
|
func (s *featureGateService) GetDailyEmailLimit(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) {
|
|
if s.selfHost {
|
|
return UnlimitedEmails, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return 0, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
|
|
if sub == nil {
|
|
return 0, nil // No subscription = no emails
|
|
}
|
|
|
|
// Free trial users = 20 emails/day
|
|
if sub.IsInFreeTrial() && !sub.HasPaidSubscription() {
|
|
return FreeTierDailyEmailLimit, nil
|
|
}
|
|
|
|
// Paid users = plan limit or unlimited
|
|
if sub.HasPaidSubscription() {
|
|
plan, err := s.planRepo.GetByID(ctx, sub.PlanID)
|
|
if err != nil || plan == nil {
|
|
return UnlimitedEmails, nil // Default to unlimited if plan not found
|
|
}
|
|
if plan.DailyCampaignLimit != nil {
|
|
return *plan.DailyCampaignLimit, nil
|
|
}
|
|
return UnlimitedEmails, nil // -1 = unlimited
|
|
}
|
|
|
|
// Trial expired = no emails
|
|
return 0, nil
|
|
}
|
|
|
|
// GetSubscriptionStatus returns subscription info for feature checks
|
|
func (s *featureGateService) GetSubscriptionStatus(ctx context.Context, orgID uuid.UUID) (*SubscriptionStatus, *errx.Error) {
|
|
if s.selfHost {
|
|
// No billing: present as an unlimited paid subscriber so callers that
|
|
// branch on status behave as fully-provisioned.
|
|
return &SubscriptionStatus{
|
|
HasSubscription: true,
|
|
IsPaidSubscriber: true,
|
|
DailyEmailLimit: UnlimitedEmails,
|
|
}, nil
|
|
}
|
|
|
|
status := &SubscriptionStatus{
|
|
HasSubscription: false,
|
|
IsInFreeTrial: false,
|
|
IsFreeTrialExpired: false,
|
|
IsPaidSubscriber: false,
|
|
DailyEmailLimit: 0,
|
|
}
|
|
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return nil, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
|
|
if sub == nil {
|
|
return status, nil
|
|
}
|
|
|
|
status.HasSubscription = true
|
|
status.IsInFreeTrial = sub.IsInFreeTrial()
|
|
status.IsFreeTrialExpired = sub.IsFreeTrialExpired()
|
|
status.IsPaidSubscriber = sub.HasPaidSubscription()
|
|
|
|
// Load plan
|
|
plan, _ := s.planRepo.GetByID(ctx, sub.PlanID)
|
|
status.Plan = plan
|
|
|
|
// Calculate daily limit
|
|
if status.IsInFreeTrial && !status.IsPaidSubscriber {
|
|
status.DailyEmailLimit = FreeTierDailyEmailLimit
|
|
} else if status.IsPaidSubscriber {
|
|
if plan != nil && plan.DailyCampaignLimit != nil {
|
|
status.DailyEmailLimit = *plan.DailyCampaignLimit
|
|
} else {
|
|
status.DailyEmailLimit = UnlimitedEmails
|
|
}
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// GetStorageLimitBytes returns the org's attachment storage quota. Paid orgs
|
|
// get the larger pool; everyone else (trial or no subscription) gets the free
|
|
// allowance so they can still attach files.
|
|
func (s *featureGateService) GetStorageLimitBytes(ctx context.Context, orgID uuid.UUID) (int64, *errx.Error) {
|
|
if s.selfHost {
|
|
return PaidStorageBytes, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return 0, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
if sub != nil && sub.HasPaidSubscription() {
|
|
return PaidStorageBytes, nil
|
|
}
|
|
return FreeTierStorageBytes, nil
|
|
}
|
|
|
|
// CanUseWritingAssistant — same trial allowance as warmup/unibox: paid
|
|
// subscribers and orgs inside their free-trial window may use the AI assistant.
|
|
func (s *featureGateService) CanUseWritingAssistant(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
return sub.HasPaidSubscription() || sub.IsInFreeTrial(), nil
|
|
}
|
|
|
|
// CanUseInboxAgent gates the inbox agent to paid subscribers only. Unlike the
|
|
// writing assistant, it drafts on inbound replies without a human first asking,
|
|
// so it is not part of the free-trial allowance.
|
|
func (s *featureGateService) CanUseInboxAgent(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
return sub.HasPaidSubscription(), nil
|
|
}
|
|
|
|
// IsPaidOrganization checks if the organization has an active paid subscription
|
|
func (s *featureGateService) IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
|
|
if s.selfHost {
|
|
// Treat as paid so downstream (warmup pool, storage) uses the full tier.
|
|
return true, nil
|
|
}
|
|
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
|
|
if err != nil {
|
|
return false, errx.New(errx.Internal, "failed to get subscription")
|
|
}
|
|
|
|
if sub == nil {
|
|
return false, nil
|
|
}
|
|
|
|
return sub.HasPaidSubscription(), nil
|
|
}
|