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
This commit is contained in:
2026-08-28 13:19:32 +10:00
parent 43ce4514c4
commit aabaa750f4
43 changed files with 2679 additions and 341 deletions

View File

@@ -0,0 +1,176 @@
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)
}

View File

@@ -26,11 +26,12 @@ type App struct {
Sender sms.Sender
Storage *storage.Client
Notifier *notify.Notifier
planCache *planCache
}
// New returns an App with the given dependencies.
func New(queries *db.Queries, tpl Templates, sender sms.Sender, store *storage.Client, ntf *notify.Notifier) *App {
return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store, Notifier: ntf}
return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store, Notifier: ntf, planCache: newPlanCache()}
}
// PageData is the root data passed to the base layout.

View File

@@ -22,10 +22,15 @@ import (
const (
alertWindow = 10 * time.Minute
alertMinDistance = 250.0 // metres
)
// Cost-protection limits (env-overridable).
maxAlertsPerTagDay = 5
maxAlertsPerIPHour = 10
// Cost-protection limits (env-overridable).
// NOTE: these are the fallback defaults for planless tags; tags whose account
// has an active paid plan use the plan's alerts_per_day / alerts_per_hour and
// the account SMS pool (see billing.go).
var (
maxAlertsPerTagDay = int32(defaultAlertsPerDay)
maxAlertsPerIPHour = int32(defaultAlertsPerHour)
)
// ScanRequest is the JSON body posted by the geolocation script.
@@ -78,10 +83,8 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
if alertSent {
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
// Metering: a successful alert consumes one credit on metered tags.
if tag.SmsAllocated > 0 {
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
}
// Account-level SMS pool: the alert draw is counted by
// CountAlertsByAccountSince (alert_sent rows) — no per-tag metering.
}
} else if reason == "lapsed" || reason == "credits" {
// Owner unlock notice (ntfy / optional SMS) — exempt from metering.
@@ -93,7 +96,7 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent})
}
// shouldAlert applies the throttle rules and sms_enabled flag.
// shouldAlert applies the account-billing gates, SMS pool, and throttle rules.
// Returns (send, reason) where reason is "lapsed" or "credits" when those
// gates blocked the alert (so the owner can be notified to renew/top up).
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) (bool, string) {
@@ -122,37 +125,68 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
}
}
// Paid-order gating with lazy expiry: non-paid status, or a paid order
// past its renews_at date, both block and trigger the unlock notice.
if tag.OrderID.Valid {
if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil {
if order.Status != "paid" || (order.RenewsAt.Valid && order.RenewsAt.Time.Before(time.Now())) {
return false, "lapsed"
// Account-level billing gating (paid subscription required) + SMS pool.
if tag.OwnerID.Valid {
billing, ok := a.resolveAccountBilling(ctx, tag.ID)
if !ok {
// No active paid subscription for the owner account => record only.
return false, "lapsed"
}
// Lazy expiry: paid order past its renews_at date is treated lapsed.
if !billing.renewsAt.IsZero() && time.Now().After(billing.renewsAt) {
return false, "lapsed"
}
// SMS pool: included (per period) + credits must have budget left.
if budget := a.smsBudgetRemaining(ctx, tag.OwnerID.Int64, billing); budget <= 0 {
return false, "credits"
}
// Plan caps replace the default constants when the plan defines them.
dailyCap := maxAlertsPerTagDay
hourlyCap := maxAlertsPerIPHour
if billing.alertsPerDay > 0 {
dailyCap = billing.alertsPerDay
}
if billing.alertsPerHour > 0 {
hourlyCap = billing.alertsPerHour
}
// Per-tag daily cap (plan or default).
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
}); err == nil && n >= int64(dailyCap) {
return false, ""
}
// Per-IP hourly rate (plan or default).
if ip != "" {
if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{
Ip: pgtype.Text{String: ip, Valid: true},
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
}); err == nil && n >= int64(hourlyCap) {
return false, ""
}
}
}
// SMS metering: a positive allocation is required; exhausted credits block alerts.
if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
return false, "credits"
}
// Per-tag daily cap.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
}); err == nil && n >= maxAlertsPerTagDay {
return false, ""
}
// Per-IP hourly rate.
if ip != "" {
if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{
Ip: pgtype.Text{String: ip, Valid: true},
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
}); err == nil && n >= maxAlertsPerIPHour {
} else {
// Transitional: unowned tag (shouldn't normally reach here since the
// public page only alerts for owned tags) — keep default caps.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
}); err == nil && n >= int64(maxAlertsPerTagDay) {
return false, ""
}
if ip != "" {
if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{
Ip: pgtype.Text{String: ip, Valid: true},
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
}); err == nil && n >= int64(maxAlertsPerIPHour) {
return false, ""
}
}
}
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
@@ -307,9 +341,14 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
notify = false
}
}
// Metering: metered tags must have credits remaining.
if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
notify = false
// Account-level billing: no active paid subscription or exhausted SMS pool
// (included + credits) => store the number, don't alert.
if notify && tag.OwnerID.Valid {
if billing, ok := a.resolveAccountBilling(r.Context(), tag.ID); !ok {
notify = false
} else if a.smsBudgetRemaining(r.Context(), tag.OwnerID.Int64, billing) <= 0 {
notify = false
}
}
if notify && fingerprint != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
@@ -335,10 +374,7 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
} else {
// Mark alerted so dedup (same phone / same device) can see it.
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
// Metering: successful contact SMS consumes a credit on metered tags.
if tag.SmsAllocated > 0 {
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
}
// Account-level SMS pool: counted via CountAlertsByAccountSince.
}
}

View File

@@ -15,8 +15,6 @@ import (
"wherewoof/frontend/internal/db"
)
const maxTagsPerAccount = 20
type accountData struct {
Tags []db.Tag
AddError string
@@ -49,13 +47,13 @@ func (a *App) AddTag(w http.ResponseWriter, r *http.Request) {
if code == "" {
data.AddError = "Enter a tag code."
} else {
cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid)
cnt, err := a.Queries.CountOwnedTags(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if cnt >= maxTagsPerAccount {
data.AddError = "Limit reached: each account can hold 20 tags."
if cnt >= a.tagCapFor(oid) {
data.AddError = fmt.Sprintf("Limit reached: each personal account can hold %d tags. Need more? Contact us about business plans.", a.tagCapFor(oid))
} else {
_, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code})
if err != nil {