Replace product-kind branching and direct position writes with enclosed database functions driven by structural product shape. Migrate grant and provision data, unify operator issuance, update subscription and expiry flows, and add migration and integration proofs.
348 lines
12 KiB
Go
348 lines
12 KiB
Go
package entitlements
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// The conferral primitive lives in five SECURITY DEFINER functions (migration
|
|
// 00005) that are the sole legal writers of core.pool_provisions,
|
|
// core.pool_provision_ladders, and core.pool_provision_transitions. This file
|
|
// is the single Go boundary that calls them: thin typed wrappers plus the one
|
|
// place that maps the functions' custom SQLSTATEs to domain errors. sqlc could
|
|
// not model the TABLE-returning signatures (design D1 fallback), so the calls
|
|
// are hand-written on the same *Queries/tx pattern; the logic stays in SQL.
|
|
|
|
// Domain errors for the four named conferral rejections. Handlers compare with
|
|
// errors.Is and surface them as field/form errors per the db-error-presentation
|
|
// convention (design D3).
|
|
var (
|
|
ErrConferralPrecedesIncumbent = errors.New("conferral activation precedes the incumbent it would supersede")
|
|
ErrConferralShapeDiverged = errors.New("conferral shape diverges from the product's current shape; align first")
|
|
ErrConferralShapeCollision = errors.New("conferral shape alignment collides with a rung held by another provision")
|
|
ErrConferralAlreadyEnded = errors.New("conferral is already ended")
|
|
)
|
|
|
|
// mapConferralError translates the conferral functions' stable custom SQLSTATEs
|
|
// (CF001-CF004) into the exported domain sentinels; other errors pass through.
|
|
func mapConferralError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) {
|
|
return err
|
|
}
|
|
switch pgErr.Code {
|
|
case "CF001":
|
|
return fmt.Errorf("%w: %s", ErrConferralPrecedesIncumbent, pgErr.Message)
|
|
case "CF002":
|
|
return fmt.Errorf("%w: %s", ErrConferralShapeDiverged, pgErr.Message)
|
|
case "CF003":
|
|
return fmt.Errorf("%w: %s", ErrConferralShapeCollision, pgErr.Message)
|
|
case "CF004":
|
|
return fmt.Errorf("%w: %s", ErrConferralAlreadyEnded, pgErr.Message)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Actor identifies who or what drives a conferral change; forwarded to the
|
|
// conferral functions' actor_type/actor_id/reason attribution columns.
|
|
type Actor struct {
|
|
ActorType string
|
|
ActorID uuid.NullUUID
|
|
Reason string
|
|
}
|
|
|
|
// ConferParams mirrors core.confer's signature. Exactly one of SubscriptionID,
|
|
// PurchaseID, or GrantID must be set (the function enforces it). ActivatedAt
|
|
// defaults to now when zero (the function forbids future activation).
|
|
type ConferParams struct {
|
|
PoolID string
|
|
ProductID string
|
|
SubscriptionID uuid.NullUUID
|
|
PurchaseID uuid.NullUUID
|
|
GrantID uuid.NullUUID
|
|
Quantity int32
|
|
ActivatedAt time.Time
|
|
EndedAt sql.NullTime
|
|
ActorType string
|
|
ActorID uuid.NullUUID
|
|
Reason sql.NullString
|
|
}
|
|
|
|
// Confer records the pool's position for (product, source) and returns the
|
|
// resulting provision id and the outcome ("created" or "noop").
|
|
func (q *Queries) Confer(ctx context.Context, arg ConferParams) (provisionID string, outcome string, err error) {
|
|
const query = `SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`
|
|
// Zero means "now": pass NULL and let the database supply it — a
|
|
// Go-sampled wall clock is always ahead of the frozen
|
|
// transaction_timestamp(), which core.confer's future guard rejects.
|
|
activatedAt := sql.NullTime{Time: arg.ActivatedAt, Valid: !arg.ActivatedAt.IsZero()}
|
|
actorType := arg.ActorType
|
|
if actorType == "" {
|
|
actorType = "system"
|
|
}
|
|
row := q.db.QueryRowContext(ctx, query,
|
|
arg.PoolID, arg.ProductID, arg.SubscriptionID, arg.PurchaseID, arg.GrantID,
|
|
arg.Quantity, activatedAt, arg.EndedAt, actorType, arg.ActorID, arg.Reason)
|
|
err = mapConferralError(row.Scan(&provisionID, &outcome))
|
|
return provisionID, outcome, err
|
|
}
|
|
|
|
// EndConferralParams mirrors core.end_conferral. Exactly one of SubscriptionID,
|
|
// PurchaseID, GrantID, or ProvisionID must be set. A source arc that resolves
|
|
// to nothing live is a success returning the empty set; an already-ended
|
|
// explicit provision id raises ErrConferralAlreadyEnded.
|
|
type EndConferralParams struct {
|
|
SubscriptionID uuid.NullUUID
|
|
PurchaseID uuid.NullUUID
|
|
GrantID uuid.NullUUID
|
|
ProvisionID uuid.NullUUID
|
|
ActorType string
|
|
ActorID uuid.NullUUID
|
|
Reason sql.NullString
|
|
EffectiveAt time.Time
|
|
}
|
|
|
|
// EndConferral ends the provision(s) matched by the selector and returns the
|
|
// ended provision ids.
|
|
func (q *Queries) EndConferral(ctx context.Context, arg EndConferralParams) ([]string, error) {
|
|
const query = `SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)`
|
|
effectiveAt := sql.NullTime{Time: arg.EffectiveAt, Valid: !arg.EffectiveAt.IsZero()}
|
|
actorType := arg.ActorType
|
|
if actorType == "" {
|
|
actorType = "system"
|
|
}
|
|
rows, err := q.db.QueryContext(ctx, query,
|
|
arg.SubscriptionID, arg.PurchaseID, arg.GrantID, arg.ProvisionID,
|
|
actorType, arg.ActorID, arg.Reason, effectiveAt)
|
|
if err != nil {
|
|
return nil, mapConferralError(err)
|
|
}
|
|
defer rows.Close()
|
|
ids := []string{}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, mapConferralError(err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, mapConferralError(rows.Err())
|
|
}
|
|
|
|
// SyncSourceStatusParams mirrors core.sync_source_status. Status must be
|
|
// "suspended" or "active"; ending is end_conferral's job alone.
|
|
type SyncSourceStatusParams struct {
|
|
Status string
|
|
SubscriptionID uuid.NullUUID
|
|
PurchaseID uuid.NullUUID
|
|
GrantID uuid.NullUUID
|
|
EffectiveAt time.Time
|
|
}
|
|
|
|
// SyncSourceStatus suspends or resumes the source's live provisions (never
|
|
// writes 'ended', never resurrects) and returns the touched provision ids.
|
|
func (q *Queries) SyncSourceStatus(ctx context.Context, arg SyncSourceStatusParams) ([]string, error) {
|
|
const query = `SELECT core.sync_source_status($1, $2, $3, $4, $5)`
|
|
effectiveAt := sql.NullTime{Time: arg.EffectiveAt, Valid: !arg.EffectiveAt.IsZero()}
|
|
rows, err := q.db.QueryContext(ctx, query,
|
|
arg.Status, arg.SubscriptionID, arg.PurchaseID, arg.GrantID, effectiveAt)
|
|
if err != nil {
|
|
return nil, mapConferralError(err)
|
|
}
|
|
defer rows.Close()
|
|
ids := []string{}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, mapConferralError(err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, mapConferralError(rows.Err())
|
|
}
|
|
|
|
// AlignConferralShapeRow is one rung reconciled by an alignment call.
|
|
type AlignConferralShapeRow struct {
|
|
PlanLadderID string
|
|
Action string // "attached" | "ended"
|
|
}
|
|
|
|
// AlignConferralShapeParams mirrors core.align_conferral_shape.
|
|
type AlignConferralShapeParams struct {
|
|
ProvisionID string
|
|
ActorType string
|
|
ActorID uuid.NullUUID
|
|
Reason sql.NullString
|
|
EffectiveAt time.Time
|
|
}
|
|
|
|
// AlignConferralShape attaches/ends rungs so an existing provision matches its
|
|
// product's current shape, returning the rungs it touched. Raises
|
|
// ErrConferralShapeCollision if a required rung is held by another provision.
|
|
func (q *Queries) AlignConferralShape(ctx context.Context, arg AlignConferralShapeParams) ([]AlignConferralShapeRow, error) {
|
|
const query = `SELECT plan_ladder_id, action FROM core.align_conferral_shape($1, $2, $3, $4, $5)`
|
|
effectiveAt := sql.NullTime{Time: arg.EffectiveAt, Valid: !arg.EffectiveAt.IsZero()}
|
|
actorType := arg.ActorType
|
|
if actorType == "" {
|
|
actorType = "system"
|
|
}
|
|
rows, err := q.db.QueryContext(ctx, query,
|
|
arg.ProvisionID, actorType, arg.ActorID, arg.Reason, effectiveAt)
|
|
if err != nil {
|
|
return nil, mapConferralError(err)
|
|
}
|
|
defer rows.Close()
|
|
out := []AlignConferralShapeRow{}
|
|
for rows.Next() {
|
|
var r AlignConferralShapeRow
|
|
if err := rows.Scan(&r.PlanLadderID, &r.Action); err != nil {
|
|
return nil, mapConferralError(err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, mapConferralError(rows.Err())
|
|
}
|
|
|
|
// UpdateConferralBoundsParams mirrors core.update_conferral_bounds. Grant-
|
|
// sourced provisions are rejected (bounds change via extend-as-replace).
|
|
type UpdateConferralBoundsParams struct {
|
|
ProvisionID string
|
|
Quantity sql.NullInt32
|
|
EndedAt sql.NullTime
|
|
SetEndedAt bool
|
|
ActorType string
|
|
ActorID uuid.NullUUID
|
|
Reason sql.NullString
|
|
}
|
|
|
|
// UpdateConferralBounds changes quantity/ended_at on a commercial provision
|
|
// without writing a position transition, returning the provision id.
|
|
func (q *Queries) UpdateConferralBounds(ctx context.Context, arg UpdateConferralBoundsParams) (string, error) {
|
|
const query = `SELECT core.update_conferral_bounds($1, $2, $3, $4, $5, $6, $7)`
|
|
actorType := arg.ActorType
|
|
if actorType == "" {
|
|
actorType = "system"
|
|
}
|
|
row := q.db.QueryRowContext(ctx, query,
|
|
arg.ProvisionID, arg.Quantity, arg.EndedAt, arg.SetEndedAt,
|
|
actorType, arg.ActorID, arg.Reason)
|
|
var id string
|
|
err := mapConferralError(row.Scan(&id))
|
|
return id, err
|
|
}
|
|
|
|
// ConferGrantInput records a grant decree and immediately confers it. Every
|
|
// grant-sourced caller (operator issuance/extend, auto-provisioning,
|
|
// reapply-defaults, demoseed) composes decree -> confer -> materialize through
|
|
// ConferGrantTx.
|
|
type ConferGrantInput struct {
|
|
ProductID string
|
|
OrgID string
|
|
GrantedByPersonID string // "" => system-authored; grant_reason must be "default"
|
|
GrantReason string
|
|
Description string
|
|
Quantity int32
|
|
ValidFrom sql.NullTime
|
|
ValidUntil sql.NullTime
|
|
ExtendsGrantID uuid.NullUUID
|
|
ActorType string // conferral actor: "operator", "system", "webhook"
|
|
ActorID uuid.NullUUID
|
|
TransitionReason string // free-text reason recorded on the position transition
|
|
}
|
|
|
|
// ConferGrantResult carries the recorded decree and the conferral outcome.
|
|
type ConferGrantResult struct {
|
|
Grant Grant
|
|
PoolID string
|
|
ProvisionID string
|
|
Outcome string // "created" | "noop"
|
|
}
|
|
|
|
// ConferGrantTx records the grant, confers it on the org's default pool, and
|
|
// materializes entitlements within the caller's transaction. A "noop" outcome
|
|
// still leaves the decree recorded (the grant is the ledger entry).
|
|
func ConferGrantTx(ctx context.Context, tx *sql.Tx, in ConferGrantInput) (*ConferGrantResult, error) {
|
|
q := New(tx)
|
|
|
|
qty := in.Quantity
|
|
if qty < 1 {
|
|
qty = 1
|
|
}
|
|
validFrom := time.Now()
|
|
if in.ValidFrom.Valid {
|
|
validFrom = in.ValidFrom.Time
|
|
}
|
|
|
|
var grantedBy uuid.NullUUID
|
|
if in.GrantedByPersonID != "" {
|
|
grantedBy = uuid.NullUUID{UUID: uuid.MustParse(in.GrantedByPersonID), Valid: true}
|
|
}
|
|
|
|
grant, err := q.CreateGrant(ctx, CreateGrantParams{
|
|
ProductID: in.ProductID,
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(in.OrgID), Valid: true},
|
|
GrantedByPersonID: grantedBy,
|
|
GrantReason: in.GrantReason,
|
|
Description: sql.NullString{String: in.Description, Valid: in.Description != ""},
|
|
Quantity: qty,
|
|
ValidFrom: validFrom,
|
|
ValidUntil: in.ValidUntil,
|
|
ExtendsGrantID: in.ExtendsGrantID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create grant: %w", err)
|
|
}
|
|
|
|
pool, err := q.GetDefaultPoolByOrgID(ctx, in.OrgID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get default pool for org %s: %w", in.OrgID, err)
|
|
}
|
|
|
|
provisionID, outcome, err := q.Confer(ctx, ConferParams{
|
|
PoolID: pool.PoolID,
|
|
ProductID: in.ProductID,
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
|
|
Quantity: qty,
|
|
ActorType: in.ActorType,
|
|
ActorID: in.ActorID,
|
|
Reason: sql.NullString{String: in.TransitionReason, Valid: in.TransitionReason != ""},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("confer grant: %w", err)
|
|
}
|
|
|
|
if err := MaterializePoolEntitlements(ctx, q, pool.PoolID); err != nil {
|
|
return nil, fmt.Errorf("materialize entitlements: %w", err)
|
|
}
|
|
|
|
return &ConferGrantResult{Grant: grant, PoolID: pool.PoolID, ProvisionID: provisionID, Outcome: outcome}, nil
|
|
}
|
|
|
|
// ConferGrant runs ConferGrantTx in its own transaction.
|
|
func ConferGrant(ctx context.Context, db *sql.DB, in ConferGrantInput) (*ConferGrantResult, error) {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
result, err := ConferGrantTx(ctx, tx, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|