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

@@ -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.
}
}