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:
176
frontend/internal/handlers/billing.go
Normal file
176
frontend/internal/handlers/billing.go
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user