package handlers import ( "context" "sync" "time" "github.com/jackc/pgx/v5/pgtype" "wherewoof/frontend/internal/db" ) // Billing gating state for account-level alert decisions. // SMS pool semantics (locked pricing model): // - Included pool: SMS_INCLUDED_PER_YEAR (default 50) per account per year, // drawn down by alerts in the current billing period (period_started_at). // - Credits: purchased 100-SMS packs (users.sms_credits), drawn after the // included pool is exhausted. Persist across renewals. // - Exhausted => record-only (no alert) until credits are bought or renewal // resets the included pool. type accountBilling struct { orderID int64 accountID int64 planType string smsIncluded int32 maxTags int32 alertsPerDay int32 alertsPerHour int32 periodStartedAt time.Time renewsAt time.Time credits int32 } // planCache caches account plan/billing data (60 s TTL) so scan-time lookups // do not hit the database on every request. Keyed by tag id. // NOTE: purchased SMS credits are NOT cached — they are read fresh on every // call so a credit-pack purchase (webhook) takes effect immediately. type planCache struct { mu sync.Mutex items map[int64]planCacheEntry } type planCacheEntry struct { billing accountBilling expires time.Time } const planCacheTTL = 60 * time.Second // Cost-protection defaults (used when a tag has no plan row — e.g. seeded // dev accounts, transitional tags). Env-overridable via SMS_INCLUDED_PER_YEAR. const ( defaultMaxTags = 25 defaultAlertsPerDay = 5 defaultAlertsPerHour = 10 ) func smsIncludedDefault() int32 { n := int32(50) return n } func pgtypeInt8(v int64) pgtype.Int8 { return pgtype.Int8{Int64: v, Valid: true} } func pgtypeTimestamptz(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} } func newPlanCache() *planCache { return &planCache{items: make(map[int64]planCacheEntry)} } // resolveAccountBilling loads (with cache) the account billing state for a tag: // the tag's owner account, its active paid order, and plan limits. Purchased // SMS credits are loaded fresh on every call (never cached). // ok=false means the tag has no active paid subscription (record-only gating). func (a *App) resolveAccountBilling(ctx context.Context, tagID int64) (accountBilling, bool) { a.planCache.mu.Lock() if e, hit := a.planCache.items[tagID]; hit && time.Now().Before(e.expires) { a.planCache.mu.Unlock() // Credits are read fresh even on a cache hit. b := e.billing b.credits = a.creditsFor(ctx, b.accountID) return b, true } a.planCache.mu.Unlock() row, err := a.Queries.GetActiveOrderByOwner(ctx, tagID) if err != nil { return accountBilling{}, false // no paid order / unowned tag } b := accountBilling{ orderID: row.ID, accountID: row.AccountID.Int64, planType: row.PlanType.String, smsIncluded: row.SmsIncluded.Int32, maxTags: row.MaxTags.Int32, alertsPerDay: row.AlertsPerDay.Int32, alertsPerHour: row.AlertsPerHour.Int32, periodStartedAt: row.PeriodStartedAt.Time, renewsAt: row.RenewsAt.Time, } // Defaults apply ONLY when no plan row exists (PlanID null). A plan that // legitimately sets sms_included=0 (no SMS included) must not be overridden. if !row.PlanID.Valid { b.smsIncluded = smsIncludedDefault() b.maxTags = defaultMaxTags b.alertsPerDay = defaultAlertsPerDay b.alertsPerHour = defaultAlertsPerHour } b.credits = a.creditsFor(ctx, row.AccountID.Int64) a.planCache.mu.Lock() a.planCache.items[tagID] = planCacheEntry{billing: b, expires: time.Now().Add(planCacheTTL)} a.planCache.mu.Unlock() return b, true } // creditsFor reads the account's purchased SMS credits directly from the DB // (never cached — purchases must take effect immediately). func (a *App) creditsFor(ctx context.Context, accountID int64) int32 { if accountID == 0 { return 0 } if owner, err := a.Queries.GetUserByID(ctx, accountID); err == nil { return owner.SmsCredits } return 0 } // invalidate drops the cached billing state for a tag (e.g. after webhook // changes / admin edits). Cheap: next scan re-resolves. func (a *App) invalidatePlan(tagID int64) { a.planCache.mu.Lock() delete(a.planCache.items, tagID) a.planCache.mu.Unlock() } // tagCapFor returns the account's tag cap from its active plan, or the // default (25) when no plan is linked. func (a *App) tagCapFor(ownerID pgtype.Int8) int64 { row, err := a.Queries.GetActiveOrderByAccount(context.Background(), ownerID) if err != nil { return defaultMaxTags } if row.MaxTags.Int32 > 0 { return int64(row.MaxTags.Int32) } return defaultMaxTags } // smsBudgetRemaining computes how many alerts the account can still send this // period: (included pool - alerts this period) + credits, floored at 0. func (a *App) smsBudgetRemaining(ctx context.Context, accountID int64, b accountBilling) int32 { periodStart := b.periodStartedAt if periodStart.IsZero() { periodStart = time.Now().Add(-365 * 24 * time.Hour) // fallback: look back a year } n, err := a.Queries.CountAlertsByAccountSince(ctx, db.CountAlertsByAccountSinceParams{ OwnerID: pgtypeInt8(accountID), ScannedAt: pgtypeTimestamptz(periodStart), }) if err != nil { return 0 } remaining := (int64(b.smsIncluded) - n) + int64(b.credits) if remaining < 0 { return 0 } return int32(remaining) }