Files
where_woof/frontend/internal/handlers/billing.go
Sam Rolfe aabaa750f4 Phase 5 billing: account-based annual sub + tag SKUs + SMS pool + tag lifecycle
- Schema: plans, orders subscription cols + plan_id + period_started_at,
  billing_events log, users.stripe_id + sms_credits, products.price_aud
  (db/schema.sql + Laravel migration, idempotent)
- Laravel/Cashier: Billable User, checkout (annual sub + one-off SKUs with
  Managed Payments tax_code), webhook controller (signature-verified,
  idempotent, BILLING_ENABLED kill-switch), account tag transitions,
  nightly reconcile, Stripe portal link, PlanResource + billing dashboard
- Go frontend: account-level gating (paid sub required), SMS pool
  (included 50/yr + credits, drawn after included), plan caps replace
  constants, 60s plan cache (credits fresh), 25-tag cap (plan max_tags)
- BillingSeeder: personal plan + 3 SKUs + dev paid orders
- Verified test-mode e2e: subscribe/paid/active/alerts, pool exhaust +
  credits resume, lapsed/suspended, cancelled/closed, recover/active,
  webhook idempotency, 25-cap, one-off SKUs, replacement, kill-switch,
  invalid signature 400
2026-08-28 13:19:32 +10:00

177 lines
5.3 KiB
Go

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)
}