Files
commerce/models/spendalert/spendalert.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

116 lines
4.5 KiB
Go

package spendalert
import (
"strings"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/mixin"
"github.com/hanzoai/commerce/util/val"
"github.com/hanzoai/orm"
)
func init() { orm.Register[SpendAlert]("spend-alert") }
// DefaultSoftPct is the soft-warn threshold applied when a row sets none: at 80%
// of Threshold the request still succeeds but carries an X-Spend-Warn signal.
const DefaultSoftPct = 80
// defaultProject is the reserved id of every org's default project. It MUST match
// the cloud wire contract (clients/principal.DefaultProject / iamauth.DefaultProject):
// an absent project header and the literal "default" denote the SAME scope, so the
// org-wide default row (Project "") and a request tagged project "default" resolve
// to one bucket — the backward-compatibility invariant.
const defaultProject = "default"
// SpendAlert is a tenant-configured budget on cumulative spend for a SCOPE
// (Project, Service). It is BOTH the soft alert it has always been AND, when
// Enforce is set, a HARD per-scope spend cap (issue #70).
//
// - Threshold (cents) is the scope's cap / alert ceiling.
// - Project / Service identify the scope. Both are INDEXED so period spend can
// be summed per scope. "" is the wildcard: empty Project AND Service = the
// org-wide default row; (P,"") = every service in project P; ("",S) = service
// S in any project.
// - Enforce=true => HARD: once period spend for the scope would exceed
// Threshold, the metering gate returns ErrSpendCapExceeded => 402. This is what
// the create API writes for a caller who says nothing about enforcement — a
// ceiling asked for is a ceiling held.
// - Enforce=false => SOFT: the cap only warns (X-Spend-Warn), never blocks. It is
// reached only by asking for it, and rows written before enforcement became the
// create default keep it: the default applies to new rows, never to stored ones.
// - RateLimitRpm>0 => the cloud ScopeRateLimit middleware caps requests/min for
// the scope => 429.
//
// The row carries NO running total: period spend is derived on demand from the
// append-only usage ledger, so a cap change takes effect on the next request with
// no reconciliation and can never drift from the debited spend.
type SpendAlert struct {
mixin.Model[SpendAlert]
UserId string `json:"userId"`
Title string `json:"title"`
Threshold int64 `json:"threshold"` // cents — the scope's spend cap / alert ceiling.
Currency string `json:"currency"`
// Scope (issue #70), both INDEXED for per-scope spend sums.
Project string `json:"project"`
Service string `json:"service"`
// Enforce turns the soft alert into a HARD cap. The field's zero value is soft, so
// a row built directly in Go warns only; the create API always sets it explicitly
// (api/billing.CreateSpendAlert), where an unstated enforcement means a hard cap.
Enforce bool `json:"enforce"`
// SoftPct is the soft-warn threshold percent (1..100). 0 => DefaultSoftPct.
SoftPct int `json:"softPct"`
// RateLimitRpm is the requests-per-minute ceiling for this scope. 0 = none.
RateLimitRpm int `json:"rateLimitRpm"`
// TriggeredAt stores the ISO timestamp when the alert last fired.
// Empty string means not yet triggered.
TriggeredAt string `json:"triggeredAt,omitempty"`
}
// EffectiveSoftPct clamps SoftPct into (0,100], defaulting an unset/invalid value
// to DefaultSoftPct so a row always yields a sane warn threshold.
func (s *SpendAlert) EffectiveSoftPct() int {
if s.SoftPct <= 0 || s.SoftPct > 100 {
return DefaultSoftPct
}
return s.SoftPct
}
// Covers reports whether this row's scope covers a request tagged (project,
// service): each of the row's axes is the wildcard "" or equals the request's.
func (s *SpendAlert) Covers(project, service string) bool {
return (s.Project == "" || s.Project == project) &&
(s.Service == "" || s.Service == service)
}
// NormalizeProject folds the default project onto the canonical "" scope so a
// request's project matches the org-wide default row exactly as the cloud
// principal resolver does. A non-default project is returned trimmed, verbatim.
func NormalizeProject(project string) string {
p := strings.TrimSpace(project)
if p == defaultProject {
return ""
}
return p
}
func (s *SpendAlert) Validator() *val.Validator {
return val.New()
}
func New(db *datastore.Datastore) *SpendAlert {
s := new(SpendAlert)
s.Init(db)
s.Parent = db.NewKey("synckey", "", 1, nil)
return s
}
func Query(db *datastore.Datastore) datastore.Query {
return db.Query("spend-alert")
}