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
}