- 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
177 lines
5.3 KiB
Go
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)
|
|
}
|