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