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:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user