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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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