Skip to content
Open
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@

- Name string enum constants `<Type><Value>`, for example
`InvoiceStatusDraft`.
- Prefer methods when behavior is intrinsic to an existing domain type. Use
freestanding functions when an operation has
no natural receiver.
- Do not extract trivial or single-use helpers unless the name captures
non-obvious domain intent. Inline pass-through wrappers.
- Do not hide type switching, validation, persistence mapping, or meaningful
Expand Down
126 changes: 126 additions & 0 deletions openmeter/credit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Credit

Credit calculates how metered usage consumes grants over time. Its primary
owner is a metered entitlement, but the calculation model is generic: an owner
provides usage, grants, usage periods, and reset behavior.

Credit balances are derived state. They are not money and they are not an
accounting ledger. See [Ledger](../ledger/README.md) for accounting state and
[Entitlements](../entitlement/README.md) for access semantics.

## Grants

A grant is an immutable, time-effective allocation of credit. Creation adds a
new grant; voiding ends its effective period without rewriting its earlier
meaning.

Usage is burnt in deterministic order:

1. lower numeric priority first;
2. earlier expiry first;
3. earlier creation first.

Recurring grants replenish their own balance on their recurrence. Owner usage
resets are different: they apply each active grant's rollover limits and begin a
new usage period.

Only active grants contribute to a balance. Expired and voided grants remain
part of historical calculations for times when they were active.

## Time semantics

Credit has one-minute temporal resolution. This is a domain constraint inherited
from minute-windowed metering, not merely a snapshot or database detail.

Grant effective times, recurrence anchors, voids, and resets are normalized to
minute boundaries. A balance read at a sub-minute timestamp rounds up to include
usage from that partial minute. Callers must not rely on ordering or balance
changes within the same minute.

History boundaries and persisted snapshots must use the same minute alignment.
Changing this resolution requires changing the metering and credit time model
together.

## Balance calculation

A balance at time `t` is calculated from:

- the owner's metering and reset configuration;
- grants effective during the calculation;
- usage-period and explicit reset boundaries;
- metered usage up to `t`.

The engine burns usage from active grants in priority order. Usage that cannot
be covered becomes overage. The result contains the remaining grant balances,
overage, and the usage-period state needed to continue the calculation.

An engine run also produces a contiguous burn-down history anchored to its
starting balance. The anchor is part of the history's meaning: the segments
alone are not enough to reconstruct balances.

## Resets

A reset closes one usage period and starts another. Active grant balances are
first constrained by their rollover limits. If overage is preserved, it is then
burnt from those rolled-over balances in the new period.

The burn-down history represents that reset transition explicitly. Rollover and
preserved overage burn must not be hidden by changing the meaning of the usage
segments on either side.

Resetting changes both balance state and the owner's usage-period timeline.
Those changes are serialized for the owner so readers cannot observe only half
of the transition.

## Balance snapshots

A balance snapshot is a complete calculation checkpoint at a specific time. It
is a cache of derived state, not an event and not part of the source history.

### Creation

Balance calculations may save a snapshot at an eligible history breakpoint.
Snapshots are kept behind a configurable grace window so recent usage remains
recalculable when events arrive late. A reset saves the balance at the reset
boundary as part of the reset transaction.

`LATEST` balances are not persisted as snapshots because a latest value is not
reusable cumulative state.

### Use

A balance calculation starts from the newest usable snapshot at or before the
query time and evaluates only the inputs after it. If no snapshot is usable,
calculation starts from the owner's measurement start.

Snapshot presence affects cost, never meaning. Removing intermediate snapshots,
removing every snapshot before the latest one, or inserting an earlier snapshot
must not change the result obtained from a later checkpoint.

Snapshots are independent checkpoints rather than a chain. Each one contains
all state required to continue the calculation, and incompatible snapshots are
ignored.

### Invalidation

A time-effective input can make saved derived state stale. The operation that
changes grants, resets, usage, or another calculation input is responsible for
invalidating affected snapshots.

If a change affects the state represented by a snapshot, that snapshot must be
replaced or invalidated together with every later snapshot. Invalidated
snapshots are excluded from selection; the next read recomputes from an earlier
usable checkpoint or from measurement start.

This rule is what makes snapshot pruning safe and prevents balance correctness
from depending on which checkpoints happen to exist.

## Boundaries

- The owner supplies metering, usage attribution, reset timing, and conversion
behavior.
- Streaming owns raw usage; credit consumes meter results.
- Credit owns grants, burn order, rollover, overage, calculation history, and
balance snapshots.
- Entitlement turns the calculated balance into an access decision.
- Billing and ledger consumers must not treat feature credit as money.
30 changes: 22 additions & 8 deletions openmeter/credit/adapter/balance_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package adapter

import (
"context"
"fmt"
"time"

"entgo.io/ent/dialect/sql"
Expand Down Expand Up @@ -47,6 +48,7 @@ func (b *balanceSnapshotRepo) GetLatestValidAt(ctx context.Context, owner models
db_balancesnapshot.Namespace(owner.Namespace),
db_balancesnapshot.AtLTE(at),
db_balancesnapshot.DeletedAtIsNil(),
db_balancesnapshot.UsageSnapshotNotNil(),
).
// in case there were multiple snapshots for the same time return the newest one
Order(db_balancesnapshot.ByAt(sql.OrderDesc()), db_balancesnapshot.ByUpdatedAt(sql.OrderDesc())).
Expand All @@ -65,20 +67,27 @@ func (b *balanceSnapshotRepo) GetLatestValidAt(ctx context.Context, owner models
func (b *balanceSnapshotRepo) Save(ctx context.Context, owner models.NamespacedID, balances []balance.Snapshot) error {
return entutils.TransactingRepoWithNoValue(ctx, b, func(ctx context.Context, rep *balanceSnapshotRepo) error {
commands := make([]*db.BalanceSnapshotCreate, 0, len(balances))
for _, balance := range balances {
for _, snapshot := range balances {
if snapshot.UsageSnapshot == nil {
return fmt.Errorf("cannot save incomplete balance snapshot at %s", snapshot.At)
}

// Keep writing the legacy usage representation for compatibility
// with old readers during the rolling migration.
command := rep.db.BalanceSnapshot.Create().
SetNamespace(owner.Namespace).
SetOwnerID(owner.ID).
SetBalance(balance.Balance()).
SetAt(balance.At).
SetGrantBalances(balance.Balances).
SetOverage(balance.Overage).
SetUsage(&balance.Usage)
SetBalance(snapshot.Balance()).
SetAt(snapshot.At).
SetGrantBalances(snapshot.Balances).
SetOverage(snapshot.Overage).
SetUsage(&snapshot.Usage).
SetUsageSnapshot(snapshot.UsageSnapshot)
// Record the conversion regime this snapshot was computed under (OM-400) so
// the resume path can refuse to reuse it under a different regime. Pointer
// GoType has no SetNillable*, so nil-guard the set (nil = raw).
if balance.UnitConfig != nil {
command = command.SetUnitConfig(balance.UnitConfig)
if snapshot.UnitConfig != nil {
command = command.SetUnitConfig(snapshot.UnitConfig)
}
commands = append(commands, command)
}
Expand All @@ -93,7 +102,12 @@ func mapBalanceSnapshotEntity(entity *db.BalanceSnapshot) balance.Snapshot {
Overage: entity.Overage,
At: entity.At.In(time.UTC),
}
if entity.UsageSnapshot != nil {
s.UsageSnapshot = entity.UsageSnapshot
}
if entity.Usage != nil {
// Hydrate legacy usage only so subsequent snapshots remain readable by
// old binaries during the rolling migration.
s.Usage = *entity.Usage
}
if entity.UnitConfig != nil {
Expand Down
47 changes: 45 additions & 2 deletions openmeter/credit/balance/balance.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"github.com/openmeterio/openmeter/openmeter/productcatalog/unitconfig"
)

func NewStartingMap(grants []grant.Grant, at time.Time) Map {
// NewStartingSnapshot returns the complete zero-usage snapshot from which
// measurement starts.
func NewStartingSnapshot(grants []grant.Grant, at time.Time) Snapshot {
balances := make(Map)
for _, grant := range grants {
if grant.ActiveAt(at) {
Expand All @@ -16,7 +18,20 @@ func NewStartingMap(grants []grant.Grant, at time.Time) Map {
balances.Set(grant.ID, 0.0)
}
}
return balances

return Snapshot{
Usage: SnapshottedUsage{
Since: at,
Usage: 0,
},
UsageSnapshot: &UsageSnapshot{
Usage: 0,
TotalGrantUsage: 0,
},
Balances: balances,
Overage: 0,
At: at,
}
}

// Represents a point in time balance of grants
Expand Down Expand Up @@ -69,11 +84,18 @@ func (g Map) ExactlyForGrants(grants []grant.Grant) bool {
return true
}

// SnapshottedUsage is the legacy usage representation whose value is relative
// to an explicitly stored starting timestamp.
//
// Deprecated: use Snapshot.UsageSnapshot for complete usage-period state.
type SnapshottedUsage struct {
Usage float64 `json:"usage"`
Since time.Time `json:"since"`
}

// IsZero reports whether the legacy usage representation is unset.
//
// Deprecated: only use this while reading legacy snapshots.
func (s SnapshottedUsage) IsZero() bool {
return s.Usage == 0.0 && s.Since.IsZero()
}
Expand All @@ -86,7 +108,11 @@ type UsageSnapshot struct {
}

type Snapshot struct {
// Usage is retained for compatibility with the legacy persistence shape.
//
// Deprecated: use UsageSnapshot for engine calculations.
Usage SnapshottedUsage

// UsageSnapshot is nil for snapshots created without complete usage-period
// state.
UsageSnapshot *UsageSnapshot
Expand All @@ -103,6 +129,23 @@ type Snapshot struct {
UnitConfig *unitconfig.UnitConfig
}

// Clone returns a snapshot whose mutable balances and unit configuration are
// independent from the source.
func (s Snapshot) Clone() Snapshot {
cloned := s
cloned.Balances = s.Balances.Clone()
if s.UsageSnapshot != nil {
usageSnapshot := *s.UsageSnapshot
cloned.UsageSnapshot = &usageSnapshot
}
if s.UnitConfig != nil {
unitConfig := s.UnitConfig.Clone()
cloned.UnitConfig = &unitConfig
}

return cloned
}

func (g Snapshot) Balance() float64 {
return g.Balances.Balance()
}
54 changes: 54 additions & 0 deletions openmeter/credit/balance/balance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,44 @@ package balance_test

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

balance "github.com/openmeterio/openmeter/openmeter/credit/balance"
"github.com/openmeterio/openmeter/openmeter/credit/grant"
)

func TestNewStartingSnapshot(t *testing.T) {
at := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
activeGrant := grant.Grant{
ID: "active",
Amount: 100,
EffectiveAt: at,
}
futureGrant := grant.Grant{
ID: "future",
Amount: 200,
EffectiveAt: at.Add(time.Hour),
}

snapshot := balance.NewStartingSnapshot([]grant.Grant{activeGrant, futureGrant}, at)

assert.Equal(t, at, snapshot.At)
assert.Equal(t, balance.Map{
activeGrant.ID: activeGrant.Amount,
futureGrant.ID: 0,
}, snapshot.Balances)
assert.Zero(t, snapshot.Overage)
assert.Equal(t, balance.SnapshottedUsage{
Since: at,
Usage: 0,
}, snapshot.Usage)
require.NotNil(t, snapshot.UsageSnapshot)
assert.Equal(t, balance.UsageSnapshot{}, *snapshot.UsageSnapshot)
}

func TestGrantBalanceMap(t *testing.T) {
makeGrant := func(id string) grant.Grant {
return grant.Grant{
Expand Down Expand Up @@ -58,3 +89,26 @@ func TestGrantBalanceMap(t *testing.T) {
}))
})
}

func TestSnapshotCloneCopiesUsageSnapshot(t *testing.T) {
snapshot := balance.Snapshot{
UsageSnapshot: &balance.UsageSnapshot{
Usage: 5,
TotalGrantUsage: 10,
},
}

cloned := snapshot.Clone()
require.NotNil(t, cloned.UsageSnapshot)
assert.Equal(t, balance.UsageSnapshot{
Usage: 5,
TotalGrantUsage: 10,
}, *cloned.UsageSnapshot)

cloned.UsageSnapshot.Usage = 15
cloned.UsageSnapshot.TotalGrantUsage = 20
assert.Equal(t, balance.UsageSnapshot{
Usage: 5,
TotalGrantUsage: 10,
}, *snapshot.UsageSnapshot)
}
2 changes: 1 addition & 1 deletion openmeter/credit/balance/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

type SnapshotRepo interface {
InvalidateAfter(ctx context.Context, owner models.NamespacedID, at time.Time) error
// The returned Snapshot might not have usage data.
// GetLatestValidAt returns the latest complete snapshot.
GetLatestValidAt(ctx context.Context, owner models.NamespacedID, at time.Time) (Snapshot, error)
Save(ctx context.Context, owner models.NamespacedID, balances []Snapshot) error
}
Expand Down
Loading
Loading