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

@@ -8,14 +8,43 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type BillingEvent struct {
ID int64 `json:"id"`
StripeEventID string `json:"stripe_event_id"`
EventType string `json:"event_type"`
OrderID pgtype.Int8 `json:"order_id"`
Payload []byte `json:"payload"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Order struct {
ID int64 `json:"id"`
AccountID pgtype.Int8 `json:"account_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Amount pgtype.Numeric `json:"amount"`
RenewsAt pgtype.Timestamptz `json:"renews_at"`
ID int64 `json:"id"`
AccountID pgtype.Int8 `json:"account_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Amount pgtype.Numeric `json:"amount"`
RenewsAt pgtype.Timestamptz `json:"renews_at"`
StripeID pgtype.Text `json:"stripe_id"`
PmType pgtype.Text `json:"pm_type"`
PmLastFour pgtype.Text `json:"pm_last_four"`
TrialEndsAt pgtype.Timestamptz `json:"trial_ends_at"`
PlanID pgtype.Int8 `json:"plan_id"`
PeriodStartedAt pgtype.Timestamptz `json:"period_started_at"`
}
type Plan struct {
ID int64 `json:"id"`
PlanType string `json:"plan_type"`
Name string `json:"name"`
PriceAud pgtype.Numeric `json:"price_aud"`
BillingInterval string `json:"billing_interval"`
SmsIncluded int32 `json:"sms_included"`
MaxTags int32 `json:"max_tags"`
AlertsPerDay int32 `json:"alerts_per_day"`
AlertsPerHour int32 `json:"alerts_per_hour"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Product struct {
@@ -26,6 +55,7 @@ type Product struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
PhotoUrl pgtype.Text `json:"photo_url"`
PriceAud pgtype.Numeric `json:"price_aud"`
}
type Scan struct {
@@ -78,4 +108,6 @@ type User struct {
IsAdmin bool `json:"is_admin"`
RememberToken pgtype.Text `json:"remember_token"`
Paused bool `json:"paused"`
StripeID pgtype.Text `json:"stripe_id"`
SmsCredits int32 `json:"sms_credits"`
}

View File

@@ -14,10 +14,21 @@ type Querier interface {
AddSmsUsed(ctx context.Context, id int64) error
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
// SMS pool draw: alerts sent for any tag owned by the account in the period.
CountAlertsByAccountSince(ctx context.Context, arg CountAlertsByAccountSinceParams) (int64, error)
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTagSinceParams) (int64, error)
// Live owned-tag count for the per-account cap: everything bound to the
// account except retired (closed) tags, including bound-but-unset codes.
CountOwnedTags(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
// The account's current paid subscription order (if any), with its plan.
GetActiveOrderByAccount(ctx context.Context, accountID pgtype.Int8) (GetActiveOrderByAccountRow, error)
// Account-level gating for a tag: resolve the tag's owner account, then the
// account's active paid subscription order (with plan). Returns zero rows
// when the tag is unowned or the account has no paid order.
GetActiveOrderByOwner(ctx context.Context, id int64) (GetActiveOrderByOwnerRow, error)
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
GetOrderByID(ctx context.Context, id int64) (Order, error)

View File

@@ -111,3 +111,38 @@ SELECT * FROM url_shortened WHERE code = $1;
-- name: GetScanByID :one
SELECT * FROM scans WHERE id = $1;
-- name: GetActiveOrderByAccount :one
-- The account's current paid subscription order (if any), with its plan.
SELECT o.*, p.plan_type, p.price_aud, p.sms_included, p.max_tags,
p.alerts_per_day, p.alerts_per_hour
FROM orders o
LEFT JOIN plans p ON p.id = o.plan_id
WHERE o.account_id = $1 AND o.status = 'paid'
ORDER BY o.id DESC
LIMIT 1;
-- name: GetActiveOrderByOwner :one
-- Account-level gating for a tag: resolve the tag's owner account, then the
-- account's active paid subscription order (with plan). Returns zero rows
-- when the tag is unowned or the account has no paid order.
SELECT o.*, p.plan_type, p.price_aud, p.sms_included, p.max_tags,
p.alerts_per_day, p.alerts_per_hour
FROM tags t
JOIN orders o ON o.account_id = t.owner_id AND o.status = 'paid'
LEFT JOIN plans p ON p.id = o.plan_id
WHERE t.id = $1
ORDER BY o.id DESC
LIMIT 1;
-- name: CountAlertsByAccountSince :one
-- SMS pool draw: alerts sent for any tag owned by the account in the period.
SELECT count(*) FROM scans s
JOIN tags t ON t.id = s.tag_id
WHERE t.owner_id = $1 AND s.alert_sent = TRUE AND s.scanned_at > $2;
-- name: CountOwnedTags :one
-- Live owned-tag count for the per-account cap: everything bound to the
-- account except retired (closed) tags, including bound-but-unset codes.
SELECT count(*) FROM tags
WHERE owner_id = $1 AND status <> 'closed';

View File

@@ -91,6 +91,25 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
return i, err
}
const countAlertsByAccountSince = `-- name: CountAlertsByAccountSince :one
SELECT count(*) FROM scans s
JOIN tags t ON t.id = s.tag_id
WHERE t.owner_id = $1 AND s.alert_sent = TRUE AND s.scanned_at > $2
`
type CountAlertsByAccountSinceParams struct {
OwnerID pgtype.Int8 `json:"owner_id"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
}
// SMS pool draw: alerts sent for any tag owned by the account in the period.
func (q *Queries) CountAlertsByAccountSince(ctx context.Context, arg CountAlertsByAccountSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countAlertsByAccountSince, arg.OwnerID, arg.ScannedAt)
var count int64
err := row.Scan(&count)
return count, err
}
const countAlertsByIPSince = `-- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2
@@ -125,6 +144,20 @@ func (q *Queries) CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTa
return count, err
}
const countOwnedTags = `-- name: CountOwnedTags :one
SELECT count(*) FROM tags
WHERE owner_id = $1 AND status <> 'closed'
`
// Live owned-tag count for the per-account cap: everything bound to the
// account except retired (closed) tags, including bound-but-unset codes.
func (q *Queries) CountOwnedTags(ctx context.Context, ownerID pgtype.Int8) (int64, error) {
row := q.db.QueryRow(ctx, countOwnedTags, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const countTagsByOwner = `-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1
`
@@ -139,7 +172,7 @@ func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (in
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits
`
type CreateUserParams struct {
@@ -167,6 +200,131 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
&i.StripeID,
&i.SmsCredits,
)
return i, err
}
const getActiveOrderByAccount = `-- name: GetActiveOrderByAccount :one
SELECT o.id, o.account_id, o.status, o.created_at, o.updated_at, o.amount, o.renews_at, o.stripe_id, o.pm_type, o.pm_last_four, o.trial_ends_at, o.plan_id, o.period_started_at, p.plan_type, p.price_aud, p.sms_included, p.max_tags,
p.alerts_per_day, p.alerts_per_hour
FROM orders o
LEFT JOIN plans p ON p.id = o.plan_id
WHERE o.account_id = $1 AND o.status = 'paid'
ORDER BY o.id DESC
LIMIT 1
`
type GetActiveOrderByAccountRow struct {
ID int64 `json:"id"`
AccountID pgtype.Int8 `json:"account_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Amount pgtype.Numeric `json:"amount"`
RenewsAt pgtype.Timestamptz `json:"renews_at"`
StripeID pgtype.Text `json:"stripe_id"`
PmType pgtype.Text `json:"pm_type"`
PmLastFour pgtype.Text `json:"pm_last_four"`
TrialEndsAt pgtype.Timestamptz `json:"trial_ends_at"`
PlanID pgtype.Int8 `json:"plan_id"`
PeriodStartedAt pgtype.Timestamptz `json:"period_started_at"`
PlanType pgtype.Text `json:"plan_type"`
PriceAud pgtype.Numeric `json:"price_aud"`
SmsIncluded pgtype.Int4 `json:"sms_included"`
MaxTags pgtype.Int4 `json:"max_tags"`
AlertsPerDay pgtype.Int4 `json:"alerts_per_day"`
AlertsPerHour pgtype.Int4 `json:"alerts_per_hour"`
}
// The account's current paid subscription order (if any), with its plan.
func (q *Queries) GetActiveOrderByAccount(ctx context.Context, accountID pgtype.Int8) (GetActiveOrderByAccountRow, error) {
row := q.db.QueryRow(ctx, getActiveOrderByAccount, accountID)
var i GetActiveOrderByAccountRow
err := row.Scan(
&i.ID,
&i.AccountID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.RenewsAt,
&i.StripeID,
&i.PmType,
&i.PmLastFour,
&i.TrialEndsAt,
&i.PlanID,
&i.PeriodStartedAt,
&i.PlanType,
&i.PriceAud,
&i.SmsIncluded,
&i.MaxTags,
&i.AlertsPerDay,
&i.AlertsPerHour,
)
return i, err
}
const getActiveOrderByOwner = `-- name: GetActiveOrderByOwner :one
SELECT o.id, o.account_id, o.status, o.created_at, o.updated_at, o.amount, o.renews_at, o.stripe_id, o.pm_type, o.pm_last_four, o.trial_ends_at, o.plan_id, o.period_started_at, p.plan_type, p.price_aud, p.sms_included, p.max_tags,
p.alerts_per_day, p.alerts_per_hour
FROM tags t
JOIN orders o ON o.account_id = t.owner_id AND o.status = 'paid'
LEFT JOIN plans p ON p.id = o.plan_id
WHERE t.id = $1
ORDER BY o.id DESC
LIMIT 1
`
type GetActiveOrderByOwnerRow struct {
ID int64 `json:"id"`
AccountID pgtype.Int8 `json:"account_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Amount pgtype.Numeric `json:"amount"`
RenewsAt pgtype.Timestamptz `json:"renews_at"`
StripeID pgtype.Text `json:"stripe_id"`
PmType pgtype.Text `json:"pm_type"`
PmLastFour pgtype.Text `json:"pm_last_four"`
TrialEndsAt pgtype.Timestamptz `json:"trial_ends_at"`
PlanID pgtype.Int8 `json:"plan_id"`
PeriodStartedAt pgtype.Timestamptz `json:"period_started_at"`
PlanType pgtype.Text `json:"plan_type"`
PriceAud pgtype.Numeric `json:"price_aud"`
SmsIncluded pgtype.Int4 `json:"sms_included"`
MaxTags pgtype.Int4 `json:"max_tags"`
AlertsPerDay pgtype.Int4 `json:"alerts_per_day"`
AlertsPerHour pgtype.Int4 `json:"alerts_per_hour"`
}
// Account-level gating for a tag: resolve the tag's owner account, then the
// account's active paid subscription order (with plan). Returns zero rows
// when the tag is unowned or the account has no paid order.
func (q *Queries) GetActiveOrderByOwner(ctx context.Context, id int64) (GetActiveOrderByOwnerRow, error) {
row := q.db.QueryRow(ctx, getActiveOrderByOwner, id)
var i GetActiveOrderByOwnerRow
err := row.Scan(
&i.ID,
&i.AccountID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.RenewsAt,
&i.StripeID,
&i.PmType,
&i.PmLastFour,
&i.TrialEndsAt,
&i.PlanID,
&i.PeriodStartedAt,
&i.PlanType,
&i.PriceAud,
&i.SmsIncluded,
&i.MaxTags,
&i.AlertsPerDay,
&i.AlertsPerHour,
)
return i, err
}
@@ -222,7 +380,7 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
}
const getOrderByID = `-- name: GetOrderByID :one
SELECT id, account_id, status, created_at, updated_at, amount, renews_at FROM orders WHERE id = $1
SELECT id, account_id, status, created_at, updated_at, amount, renews_at, stripe_id, pm_type, pm_last_four, trial_ends_at, plan_id, period_started_at FROM orders WHERE id = $1
`
func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
@@ -236,6 +394,12 @@ func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
&i.UpdatedAt,
&i.Amount,
&i.RenewsAt,
&i.StripeID,
&i.PmType,
&i.PmLastFour,
&i.TrialEndsAt,
&i.PlanID,
&i.PeriodStartedAt,
)
return i, err
}
@@ -364,7 +528,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused FROM users WHERE email = $1
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
@@ -380,12 +544,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
&i.StripeID,
&i.SmsCredits,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused FROM users WHERE id = $1
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@@ -401,6 +567,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
&i.StripeID,
&i.SmsCredits,
)
return i, err
}
@@ -690,7 +858,7 @@ const upsertAdmin = `-- name: UpsertAdmin :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, is_admin = true
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused, stripe_id, sms_credits
`
type UpsertAdminParams struct {
@@ -712,6 +880,8 @@ func (q *Queries) UpsertAdmin(ctx context.Context, arg UpsertAdminParams) (User,
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
&i.StripeID,
&i.SmsCredits,
)
return i, err
}

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 {