openmeter / subscription /subscriptionspec.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 7)
16cdcb7 verified
Raw
History Blame Contribute Delete
38.3 kB
package subscription
import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/samber/lo"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/entitlement"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/pkg/convert"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/timeutil"
)
// Spec is the complete generic specification of how a Subscription (sub)Entity should look like.
//
// It is generic as it doesn't include any hard references or exact timestamps or the sort.
//
// Both Subscription, SubscriptionPhase and SubscriptionItem can have 3 interfaces defineing their spec.
// First is which is inferred from the plan content, it is suffixed with PlanInput.
// Second is which is inferred form the customer, it is suffixed with CustomerInput.
// Third is the final spec which is a combination of the above two, it is suffixed with Spec.
type CreateSubscriptionPlanInput struct {
Plan *PlanRef `json:"plan"`
// BillingCadence is the default billing cadence for subscriptions.
BillingCadence datetime.ISODuration `json:"billing_cadence"`
// ProRatingConfig is the default pro-rating configuration for subscriptions.
ProRatingConfig productcatalog.ProRatingConfig `json:"pro_rating_config"`
// SettlementMode is the settlement mode for the subscription.
SettlementMode productcatalog.SettlementMode `json:"settlement_mode"`
}
type CreateSubscriptionCustomerInput struct {
models.MetadataModel `json:",inline"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CustomerId string `json:"customerId"`
Currency currencyx.Code `json:"currency"`
ActiveFrom time.Time `json:"activeFrom,omitempty"`
ActiveTo *time.Time `json:"activeTo,omitempty"`
BillingAnchor time.Time `json:"billingAnchor,omitempty"`
Annotations models.Annotations `json:"annotations"`
}
type SubscriptionSpec struct {
CreateSubscriptionPlanInput `json:",inline"`
CreateSubscriptionCustomerInput `json:",inline"`
// We use pointers so Patches can manipulate the spec
Phases map[string]*SubscriptionPhaseSpec `json:"phases"`
}
func (s *SubscriptionSpec) ToCreateSubscriptionEntityInput(ns string) CreateSubscriptionEntityInput {
return CreateSubscriptionEntityInput{
NamespacedModel: models.NamespacedModel{
Namespace: ns,
},
Plan: s.Plan,
CustomerId: s.CustomerId,
Currency: s.Currency,
BillingCadence: s.BillingCadence,
ProRatingConfig: s.ProRatingConfig,
SettlementMode: s.SettlementMode,
BillingAnchor: s.BillingAnchor,
MetadataModel: s.MetadataModel,
Annotations: s.Annotations,
Name: s.Name,
Description: s.Description,
CadencedModel: models.CadencedModel{
ActiveFrom: s.ActiveFrom,
ActiveTo: s.ActiveTo,
},
}
}
func (s *SubscriptionSpec) GetPhaseCadence(phaseKey string) (models.CadencedModel, error) {
var def models.CadencedModel
phase, exists := s.Phases[phaseKey]
if !exists {
return def, fmt.Errorf("phase %s not found", phaseKey)
}
// Lets calculate the phase Cadence for the new spec
phaseStartTime, _ := phase.StartAfter.AddTo(s.ActiveFrom)
var phaseEndTime *time.Time
// Find the next phase if any
sortedPhaseSpecs := s.GetSortedPhases()
for i, p := range sortedPhaseSpecs {
if p.PhaseKey == phase.PhaseKey && i+1 < len(sortedPhaseSpecs) {
nextPhase := sortedPhaseSpecs[i+1]
et, _ := nextPhase.StartAfter.AddTo(s.ActiveFrom)
phaseEndTime = &et
break
}
}
// If the subscription is scheduled to end, we have to check whether that end time is before the phase end time
if s.ActiveTo != nil {
if phaseEndTime == nil {
phaseEndTime = s.ActiveTo
} else if s.ActiveTo.Before(*phaseEndTime) {
phaseEndTime = s.ActiveTo
}
}
cadence := models.CadencedModel{
ActiveFrom: phaseStartTime.UTC(),
ActiveTo: convert.SafeDeRef(phaseEndTime, func(t time.Time) *time.Time {
// The phase end time cannot be before the phase start time
if t.Before(phaseStartTime) {
t = phaseStartTime
}
return lo.ToPtr(t.UTC())
}),
}
return cadence, nil
}
// GetSortedPhases returns the subscription phase references time sorted order ASC.
func (s *SubscriptionSpec) GetSortedPhases() []*SubscriptionPhaseSpec {
phases := make([]*SubscriptionPhaseSpec, 0, len(s.Phases))
for _, phase := range s.Phases {
phases = append(phases, phase)
}
slices.SortStableFunc(phases, func(i, j *SubscriptionPhaseSpec) int {
iTime, _ := i.StartAfter.AddTo(s.ActiveFrom)
jTime, _ := j.StartAfter.AddTo(s.ActiveFrom)
diff := iTime.Compare(jTime)
if diff != 0 {
return diff
}
// We do a best effort tie-breaker
// SortHint "should" be present for all these cases
if i.SortHint != nil && j.SortHint != nil {
diff = int(*i.SortHint) - int(*j.SortHint)
}
if diff != 0 {
return diff
}
// We still want this to be deterministic so we use phase key as a last resort
return strings.Compare(i.PhaseKey, j.PhaseKey)
})
return phases
}
func (s *SubscriptionSpec) GetCurrentPhaseAt(t time.Time) (*SubscriptionPhaseSpec, bool) {
var current *SubscriptionPhaseSpec
for _, phase := range s.GetSortedPhases() {
if st, _ := phase.StartAfter.AddTo(s.ActiveFrom); !st.After(t) {
current = phase
} else {
break
}
}
// The subscription is already expired at that point
if s.ActiveTo != nil && !s.ActiveTo.After(t) {
current = nil
}
if current == nil {
return nil, false
}
return current, true
}
func (s *SubscriptionSpec) HasEntitlements() bool {
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
return p.HasEntitlements()
})
}
func (s *SubscriptionSpec) HasBillables() bool {
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
return p.HasBillables()
})
}
func (s *SubscriptionSpec) HasMeteredBillables() bool {
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
return p.HasMeteredBillables()
})
}
func (s *SubscriptionSpec) HasUnitConfig() bool {
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
return p.HasUnitConfig()
})
}
// For a phase in an Aligned subscription, there's a single aligned BillingPeriod for all items in that phase.
// The period starts with the phase and iterates every subscription.BillingCadence duration, but can be reanchored to the time of an edit.
func (s *SubscriptionSpec) GetAlignedBillingPeriodAt(at time.Time) (timeutil.ClosedPeriod, error) {
var def timeutil.ClosedPeriod
// Let's be defensive just in case
if s.BillingCadence.IsZero() {
return def, fmt.Errorf("subscription has no billing cadence")
}
// First, let's try to find the phase at the given time.
subCad := models.CadencedModel{
ActiveFrom: s.ActiveFrom,
ActiveTo: s.ActiveTo,
}
var phase *SubscriptionPhaseSpec
switch {
// If the subscription is active at that time we'll have an active phase.
case subCad.IsActiveAt(at):
p, ok := s.GetCurrentPhaseAt(at)
if !ok {
return def, fmt.Errorf("no active phase found for active subscription at %s", at)
}
phase = p
case at.Before(subCad.ActiveFrom):
return def, NewErrSubscriptionBillingPeriodQueriedBeforeSubscriptionStart(at, subCad.ActiveFrom)
default:
if subCad.ActiveTo == nil {
// impossible, but lets be defensive and not panic
return def, fmt.Errorf("subscription has no activeTo date but is not active at %s", at)
}
for _, p := range s.GetSortedPhases() {
cad, err := s.GetPhaseCadence(p.PhaseKey)
if err != nil {
return def, fmt.Errorf("failed to get phase cadence for phase %s: %w", p.PhaseKey, err)
}
if cad.ActiveFrom.After(*subCad.ActiveTo) {
break
}
phase = p
}
}
// Let's be defensive once again
if phase == nil {
return def, fmt.Errorf("no phase found for subscription billing period calculation at %s", at)
}
// TODO(galexi, OM-1418): implement reanchoring
// We will use the subscription billing anchor as the cadence anchor
billingRecurrence, err := timeutil.NewRecurrenceFromISODuration(s.BillingCadence, s.BillingAnchor)
if err != nil {
return def, fmt.Errorf("failed to get billing recurrence for phase %s: %w", phase.PhaseKey, err)
}
period, err := billingRecurrence.GetPeriodAt(at)
if err != nil {
return def, fmt.Errorf("failed to get billing period for phase %s at %s: %w", phase.PhaseKey, at, err)
}
// The billing period must be contained within the phase
phaseCadence, err := s.GetPhaseCadence(phase.PhaseKey)
if err != nil {
return def, fmt.Errorf("failed to get phase cadence for phase %s: %w", phase.PhaseKey, err)
}
if phaseCadence.ActiveTo != nil && phaseCadence.ActiveTo.Before(period.To) {
period.To = *phaseCadence.ActiveTo
}
if phaseCadence.ActiveFrom.After(period.From) {
period.From = phaseCadence.ActiveFrom
}
return period, nil
}
// SyncAnnotations serves as a central place where we can calculate annotation default for the Subscription contents
func (s *SubscriptionSpec) SyncAnnotations() error {
for _, phase := range s.GetSortedPhases() {
if err := phase.SyncAnnotations(); err != nil {
return fmt.Errorf("failed to sync annotations for phase %s: %w", phase.PhaseKey, err)
}
}
return nil
}
func (s *SubscriptionSpec) Validate() error {
// All consistency checks should happen here
var errs []error
// Let's validate the billing anchor
// - is present
if s.BillingAnchor.IsZero() {
errs = append(errs, ErrSubscriptionBillingAnchorIsRequired)
}
sortedPhases := s.GetSortedPhases()
for idx, phase := range sortedPhases {
// Let's validate that if there are phases with the same start time, they have sort hint present
if idx > 0 {
prevPhase := sortedPhases[idx-1]
if prevPhase.StartAfter.Equal(&phase.StartAfter) {
if phase.SortHint == nil || prevPhase.SortHint == nil {
errs = append(errs, fmt.Errorf("phase %s has the same start time as phase %s but no sort hint", phase.PhaseKey, prevPhase.PhaseKey))
}
}
}
cadence, err := s.GetPhaseCadence(phase.PhaseKey)
if err != nil {
errs = append(errs, fmt.Errorf("during validating spec failed to get phase cadence for phase %s: %w", phase.PhaseKey, err))
continue
}
if err := phase.Validate(cadence); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (s *SubscriptionSpec) ValidateAlignment() error {
var errs []error
for _, phase := range s.GetSortedPhases() {
for _, itemsByKey := range phase.GetBillableItemsByKey() {
for idx, item := range itemsByKey {
fieldSelector := models.NewFieldSelectorGroup(
phase.FieldDescriptor(),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector(item.ItemKey).
WithExpression(models.NewFieldArrIndex(idx)),
)
rateCard := item.RateCard
if rateCard.GetBillingCadence() != nil {
if err := productcatalog.ValidateBillingCadencesAlign(s.BillingCadence, lo.FromPtr(rateCard.GetBillingCadence())); err != nil {
errs = append(errs, models.ErrorWithFieldPrefix(fieldSelector, err))
}
}
}
}
}
return errors.Join(errs...)
}
var _ models.CadenceComparable = SubscriptionSpec{}
func (s SubscriptionSpec) GetCadence() models.CadencedModel {
return models.CadencedModel{
ActiveFrom: s.ActiveFrom,
ActiveTo: s.ActiveTo,
}
}
type CreateSubscriptionPhasePlanInput struct {
PhaseKey string `json:"key"`
StartAfter datetime.ISODuration `json:"startAfter"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
SortHint *uint8 `json:"sortHint,omitempty"`
}
func (i CreateSubscriptionPhasePlanInput) Validate() error {
if i.PhaseKey == "" {
return fmt.Errorf("phase key is required")
}
if i.Name == "" {
return fmt.Errorf("name is required")
}
return nil
}
type CreateSubscriptionPhaseCustomerInput struct {
models.MetadataModel `json:",inline"`
}
type RemoveSubscriptionPhaseShifting int
const (
RemoveSubscriptionPhaseShiftNext RemoveSubscriptionPhaseShifting = iota
RemoveSubscriptionPhaseShiftPrev
)
func (s RemoveSubscriptionPhaseShifting) Validate() error {
if s != RemoveSubscriptionPhaseShiftNext && s != RemoveSubscriptionPhaseShiftPrev {
return fmt.Errorf("invalid RemoveSubscriptionPhaseShifting value %d", s)
}
return nil
}
type RemoveSubscriptionPhaseInput struct {
Shift RemoveSubscriptionPhaseShifting `json:"shift"`
}
type CreateSubscriptionPhaseInput struct {
// Duration is required exactly in cases where the phase wouldn't be the last phase.
Duration *datetime.ISODuration `json:"duration"`
CreateSubscriptionPhasePlanInput
CreateSubscriptionPhaseCustomerInput
}
func (i CreateSubscriptionPhaseInput) Validate() error {
if err := i.CreateSubscriptionPhasePlanInput.Validate(); err != nil {
return err
}
return nil
}
type SubscriptionPhaseSpec struct {
// Duration is not part of the Spec by design
CreateSubscriptionPhasePlanInput `json:",inline"`
CreateSubscriptionPhaseCustomerInput `json:",inline"`
// In each key, for each phase, we have a list of item specs to account for mid-phase changes
ItemsByKey map[string][]*SubscriptionItemSpec `json:"itemsByKey"`
}
func (s SubscriptionPhaseSpec) ToCreateSubscriptionPhaseEntityInput(
subscription Subscription,
activeFrom time.Time,
) CreateSubscriptionPhaseEntityInput {
return CreateSubscriptionPhaseEntityInput{
ActiveFrom: activeFrom,
NamespacedModel: models.NamespacedModel{
Namespace: subscription.Namespace,
},
MetadataModel: s.MetadataModel,
SubscriptionID: subscription.ID,
Key: s.PhaseKey,
Name: s.Name,
Description: s.Description,
StartAfter: s.StartAfter,
SortHint: s.SortHint,
}
}
// GetBillableItemsByKey returns a map of billable items by key
func (s SubscriptionPhaseSpec) GetBillableItemsByKey() map[string][]*SubscriptionItemSpec {
res := make(map[string][]*SubscriptionItemSpec)
for key, items := range s.ItemsByKey {
for _, item := range items {
if item.RateCard.AsMeta().Price != nil {
if res[key] == nil {
res[key] = make([]*SubscriptionItemSpec, 0)
}
res[key] = append(res[key], item)
}
}
}
return res
}
func (s SubscriptionPhaseSpec) HasEntitlements() bool {
return lo.SomeBy(lo.Flatten(lo.Values(s.ItemsByKey)), func(item *SubscriptionItemSpec) bool {
return item.RateCard.AsMeta().EntitlementTemplate != nil
})
}
func (s SubscriptionPhaseSpec) HasMeteredBillables() bool {
return lo.SomeBy(lo.Flatten(lo.Values(s.ItemsByKey)), func(item *SubscriptionItemSpec) bool {
return item.RateCard.AsMeta().Price != nil && item.RateCard.AsMeta().Price.Type() != productcatalog.FlatPriceType
})
}
func (s SubscriptionPhaseSpec) HasUnitConfig() bool {
return lo.SomeBy(lo.Flatten(lo.Values(s.ItemsByKey)), func(item *SubscriptionItemSpec) bool {
return item.RateCard.AsMeta().UnitConfig != nil
})
}
func (s SubscriptionPhaseSpec) HasBillables() bool {
return len(s.GetBillableItemsByKey()) > 0
}
func (s SubscriptionPhaseSpec) SyncAnnotations() error {
for _, items := range s.ItemsByKey {
for idx, item := range items {
if err := item.SyncAnnotations(); err != nil {
return fmt.Errorf("failed to sync annotations for item %s at index %d: %w", item.ItemKey, idx, err)
}
}
}
return nil
}
func (s SubscriptionPhaseSpec) FieldDescriptor() *models.FieldDescriptor {
return models.NewFieldSelectorGroup(
models.NewFieldSelector("phases"),
models.NewFieldSelector(s.PhaseKey),
).WithAttributes(models.Attributes{
PhaseDescriptor: true,
})
}
func (s SubscriptionPhaseSpec) Validate(
phaseCadence models.CadencedModel,
) error {
var errs []error
phaseSelector := s.FieldDescriptor()
// Phase StartAfter really should not be negative
if s.StartAfter.IsNegative() {
errs = append(errs, models.ErrorWithFieldPrefix(
phaseSelector,
ErrSubscriptionPhaseStartAfterIsNegative,
))
}
// Let's validate that the phase is not empty
flat := lo.Flatten(lo.Values(s.ItemsByKey))
if len(flat) == 0 {
errs = append(errs, models.ErrorWithFieldPrefix(
phaseSelector,
ErrSubscriptionPhaseHasNoItems.With(
AllowedDuringApplyingToSpecError(),
),
))
}
for key, items := range s.ItemsByKey {
for idx, item := range items {
itemSelector := models.NewFieldSelectorGroup(
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector(key).
WithExpression(models.NewFieldArrIndex(idx)),
)
// Let's validate key is correct
if item.ItemKey != key {
errs = append(errs, models.ErrorWithFieldPrefix(
itemSelector.WithPrefix(phaseSelector),
ErrSubscriptionPhaseItemHistoryKeyMismatch,
))
}
// Let's validate the phase linking is correct
if item.PhaseKey != s.PhaseKey {
errs = append(errs, models.ErrorWithFieldPrefix(
itemSelector.WithPrefix(phaseSelector),
ErrSubscriptionPhaseItemKeyMismatchWithPhaseKey,
))
}
// Let's validate the item contents
if err := item.Validate(); err != nil {
errs = append(errs, models.ErrorWithFieldPrefix(
itemSelector.WithPrefix(phaseSelector),
err,
))
}
}
// Let's validate that the items form a valid non-overlapping timeline
cadences := make([]models.CadencedModel, 0, len(items))
for i := range items {
cadence := items[i].GetCadence(phaseCadence)
cadences = append(cadences, cadence)
}
timeline := models.CadenceList[models.CadencedModel](cadences)
// We guarantee here that the sorting of items is the same as the sorting of the timeline, which is also a correct sorting
if !timeline.IsSorted() {
errs = append(errs, fmt.Errorf("items for key %s are not sorted", key))
}
if overlaps := timeline.GetOverlaps(); len(overlaps) > 0 {
for _, overlap := range overlaps {
itemSpec1 := items[overlap.Index1]
itemSpec2 := items[overlap.Index2]
// error for first item
errs = append(errs, models.ErrorWithFieldPrefix(
phaseSelector,
ErrSubscriptionItemHistoryOverlap.WithField(
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector(key).
WithExpression(models.NewFieldArrIndex(overlap.Index1)),
).WithAttrs(models.Attributes{
"overlaps_with_idx": overlap.Index2,
"cadence": overlap.Item1,
"spec": itemSpec1,
}),
))
// error for second item
errs = append(errs, models.ErrorWithFieldPrefix(
phaseSelector,
ErrSubscriptionItemHistoryOverlap.WithField(
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector(key).
WithExpression(models.NewFieldArrIndex(overlap.Index2)),
).WithAttrs(models.Attributes{
"overlaps_with_idx": overlap.Index1,
"cadence": overlap.Item2,
"spec": itemSpec2,
}),
))
}
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
type CreateSubscriptionItemPlanInput struct {
PhaseKey string `json:"phaseKey"`
ItemKey string `json:"itemKey"`
RateCard productcatalog.RateCard `json:"rateCard"`
}
func (i *CreateSubscriptionItemPlanInput) UnmarshalJSON(b []byte) error {
var serdeTyp struct {
RateCard productcatalog.RateCardSerde `json:"rateCard"`
}
if err := json.Unmarshal(b, &serdeTyp); err != nil {
return fmt.Errorf("failed to JSON deserialize SubscriptionItemSpec: %w", err)
}
serde := struct {
RateCard productcatalog.RateCard
PhaseKey string `json:"phaseKey"`
ItemKey string `json:"itemKey"`
}{
RateCard: i.RateCard,
PhaseKey: i.PhaseKey,
ItemKey: i.ItemKey,
}
switch serdeTyp.RateCard.Type {
case productcatalog.FlatFeeRateCardType:
serde.RateCard = &productcatalog.FlatFeeRateCard{}
case productcatalog.UsageBasedRateCardType:
serde.RateCard = &productcatalog.UsageBasedRateCard{}
default:
return fmt.Errorf("invalid RateCard type: %s", serdeTyp.RateCard.Type)
}
if err := json.Unmarshal(b, &serde); err != nil {
return fmt.Errorf("failed to JSON deserialize SubscriptionItemPlanInput: %w", err)
}
i.RateCard = serde.RateCard
i.PhaseKey = serde.PhaseKey
i.ItemKey = serde.ItemKey
return nil
}
type CreateSubscriptionItemCustomerInput struct {
ActiveFromOverrideRelativeToPhaseStart *datetime.ISODuration `json:"activeFromOverrideRelativeToPhaseStart,omitempty"`
ActiveToOverrideRelativeToPhaseStart *datetime.ISODuration `json:"activeToOverrideRelativeToPhaseStart,omitempty"`
BillingBehaviorOverride
}
func (i *CreateSubscriptionItemCustomerInput) UnmarshalJSON(b []byte) error {
var serde struct {
ActiveFromOverrideRelativeToPhaseStart *string `json:"activeFromOverrideRelativeToPhaseStart,omitempty"`
ActiveToOverrideRelativeToPhaseStart *string `json:"activeToOverrideRelativeToPhaseStart,omitempty"`
BillingBehaviorOverride
}
if err := json.Unmarshal(b, &serde); err != nil {
return fmt.Errorf("failed to JSON deserialize CreateSubscriptionItemCustomerInput: %w", err)
}
var def CreateSubscriptionItemCustomerInput
def.BillingBehaviorOverride = serde.BillingBehaviorOverride
if serde.ActiveFromOverrideRelativeToPhaseStart != nil {
activeFrom, err := datetime.ISODurationString(*serde.ActiveFromOverrideRelativeToPhaseStart).Parse()
if err != nil {
return fmt.Errorf("failed to parse active from override relative to phase start: %w", err)
}
def.ActiveFromOverrideRelativeToPhaseStart = &activeFrom
}
if serde.ActiveToOverrideRelativeToPhaseStart != nil {
activeTo, err := datetime.ISODurationString(*serde.ActiveToOverrideRelativeToPhaseStart).Parse()
if err != nil {
return fmt.Errorf("failed to parse active to override relative to phase start: %w", err)
}
def.ActiveToOverrideRelativeToPhaseStart = &activeTo
}
*i = def
return nil
}
type CreateSubscriptionItemInput struct {
Annotations models.Annotations `json:"annotations"`
CreateSubscriptionItemPlanInput `json:",inline"`
CreateSubscriptionItemCustomerInput `json:",inline"`
}
func (i *CreateSubscriptionItemInput) UnmarshalJSON(b []byte) error {
var annSerde struct {
Annotations models.Annotations `json:"annotations"`
}
if err := json.Unmarshal(b, &annSerde); err != nil {
return fmt.Errorf("failed to JSON deserialize CreateSubscriptionItemInput: %w", err)
}
var planSerde CreateSubscriptionItemPlanInput
if err := json.Unmarshal(b, &planSerde); err != nil {
return fmt.Errorf("failed to JSON deserialize CreateSubscriptionItemInput: %w", err)
}
var customerSerde CreateSubscriptionItemCustomerInput
if err := json.Unmarshal(b, &customerSerde); err != nil {
return fmt.Errorf("failed to JSON deserialize CreateSubscriptionItemInput: %w", err)
}
i.Annotations = annSerde.Annotations
i.CreateSubscriptionItemPlanInput = planSerde
i.CreateSubscriptionItemCustomerInput = customerSerde
return nil
}
type SubscriptionItemSpec struct {
CreateSubscriptionItemInput `json:",inline"`
}
func (s SubscriptionItemSpec) GetCadence(phaseCadence models.CadencedModel) models.CadencedModel {
start := phaseCadence.ActiveFrom
if s.ActiveFromOverrideRelativeToPhaseStart != nil {
start, _ = s.ActiveFromOverrideRelativeToPhaseStart.AddTo(phaseCadence.ActiveFrom)
}
if phaseCadence.ActiveTo != nil {
if phaseCadence.ActiveTo.Before(start) {
// If the intended start time is after the intended end time of the phase, the item will have 0 lifetime at the end of the phase
// This scenario is possible when Subscriptions are canceled (before the phase ends)
return models.CadencedModel{
ActiveFrom: *phaseCadence.ActiveTo,
ActiveTo: phaseCadence.ActiveTo,
}
}
}
end := phaseCadence.ActiveTo
if s.ActiveToOverrideRelativeToPhaseStart != nil {
endTime, _ := s.ActiveToOverrideRelativeToPhaseStart.AddTo(phaseCadence.ActiveFrom)
if phaseCadence.ActiveTo != nil && phaseCadence.ActiveTo.Before(endTime) {
// Phase Cadence overrides item cadence in all cases
endTime = *phaseCadence.ActiveTo
}
end = &endTime
}
return models.CadencedModel{
ActiveFrom: start,
ActiveTo: end,
}
}
type GetFullServicePeriodAtInput struct {
SubscriptionCadence models.CadencedModel
PhaseCadence models.CadencedModel
ItemCadence models.CadencedModel
At time.Time
AlignedBillingAnchor time.Time
}
func (i GetFullServicePeriodAtInput) isEndOfSubscription() bool {
return lo.TernaryF(i.SubscriptionCadence.ActiveTo == nil, func() bool { return false }, func() bool { return i.SubscriptionCadence.ActiveTo.Equal(i.At) })
}
func (i GetFullServicePeriodAtInput) Validate() error {
if i.At.IsZero() {
return fmt.Errorf("at is zero")
}
if i.AlignedBillingAnchor.IsZero() {
return fmt.Errorf("aligned billing anchor is zero")
}
if !i.SubscriptionCadence.AsPeriod().ContainsInclusive(i.At) {
return fmt.Errorf("subscription is not active at %s: [%s, %s]", i.At, i.SubscriptionCadence.ActiveFrom, i.SubscriptionCadence.ActiveTo)
}
// We might attempt to bill these
isEndOfSubscription := i.isEndOfSubscription()
if !i.PhaseCadence.IsActiveAt(i.At) && !isEndOfSubscription {
return fmt.Errorf("phase is not active at %s: [%s, %s]", i.At, i.PhaseCadence.ActiveFrom, i.PhaseCadence.ActiveTo)
}
// We might attempt to bill these
isZeroLengthLastItem := i.At.Equal(i.ItemCadence.ActiveFrom) && i.ItemCadence.ActiveTo != nil && i.ItemCadence.ActiveFrom.Equal(*i.ItemCadence.ActiveTo)
if !i.ItemCadence.IsActiveAt(i.At) && !isZeroLengthLastItem {
return fmt.Errorf("item is not active at %s: [%s, %s]", i.At, i.ItemCadence.ActiveFrom, i.ItemCadence.ActiveTo)
}
return nil
}
// GetFullServicePeriodAt returns the full service period for an item at a given time
// To get the de-facto service period, use the intersection of the item's activity with the returned period.
func (s SubscriptionItemSpec) GetFullServicePeriodAt(
inp GetFullServicePeriodAtInput,
) (timeutil.ClosedPeriod, error) {
if err := inp.Validate(); err != nil {
return timeutil.ClosedPeriod{}, err
}
billingCadence := s.RateCard.GetBillingCadence()
if billingCadence == nil {
end := inp.ItemCadence.ActiveFrom
if inp.ItemCadence.ActiveTo != nil {
end = *inp.ItemCadence.ActiveTo
}
if inp.PhaseCadence.ActiveTo != nil {
end = *inp.PhaseCadence.ActiveTo
}
return timeutil.ClosedPeriod{
From: inp.ItemCadence.ActiveFrom,
To: end,
}, nil
}
rec, err := timeutil.NewRecurrenceFromISODuration(*billingCadence, inp.AlignedBillingAnchor)
if err != nil {
return timeutil.ClosedPeriod{}, fmt.Errorf("failed to get recurrence from ISO duration: %w", err)
}
return rec.GetPeriodAt(inp.At)
}
func (s SubscriptionItemSpec) ToCreateSubscriptionItemEntityInput(
phaseID models.NamespacedID,
phaseCadence models.CadencedModel,
entitlement *entitlement.Entitlement,
) (CreateSubscriptionItemEntityInput, error) {
itemCadence := s.GetCadence(phaseCadence)
res := CreateSubscriptionItemEntityInput{
NamespacedModel: models.NamespacedModel{
Namespace: phaseID.Namespace,
},
Annotations: s.Annotations,
CadencedModel: itemCadence,
ActiveFromOverrideRelativeToPhaseStart: s.CreateSubscriptionItemCustomerInput.ActiveFromOverrideRelativeToPhaseStart,
ActiveToOverrideRelativeToPhaseStart: s.CreateSubscriptionItemCustomerInput.ActiveToOverrideRelativeToPhaseStart,
PhaseID: phaseID.ID,
Key: s.ItemKey,
RateCard: s.CreateSubscriptionItemPlanInput.RateCard,
Name: s.RateCard.AsMeta().Name,
Description: s.RateCard.AsMeta().Description,
BillingBehaviorOverride: s.BillingBehaviorOverride,
}
if entitlement != nil {
res.EntitlementID = &entitlement.ID
}
return res, nil
}
type ToScheduleSubscriptionEntitlementInputOptions struct {
Customer customer.Customer
Cadence models.CadencedModel
PhaseStart time.Time
AlignedBillingAnchor time.Time
}
func (s SubscriptionItemSpec) ToScheduleSubscriptionEntitlementInput(
opts ToScheduleSubscriptionEntitlementInputOptions,
) (ScheduleSubscriptionEntitlementInput, bool, error) {
var def ScheduleSubscriptionEntitlementInput
meta := s.RateCard.AsMeta()
if meta.EntitlementTemplate == nil {
return def, false, nil
}
if meta.FeatureKey == nil {
return def, true, fmt.Errorf("feature is required for rate card where entitlement is present: %s", s.ItemKey)
}
t := meta.EntitlementTemplate.Type()
scheduleInput := entitlement.CreateEntitlementInputs{
EntitlementType: t,
Namespace: opts.Customer.Namespace,
ActiveFrom: lo.ToPtr(opts.Cadence.ActiveFrom),
ActiveTo: opts.Cadence.ActiveTo,
FeatureKey: meta.FeatureKey,
UsageAttribution: opts.Customer.GetUsageAttribution(),
}
switch t {
case entitlement.EntitlementTypeBoolean:
tpl, err := meta.EntitlementTemplate.AsBoolean()
if err != nil {
return def, true, fmt.Errorf("failed to get boolean entitlement template: %w", err)
}
scheduleInput.Metadata = tpl.Metadata
case entitlement.EntitlementTypeStatic:
tpl, err := meta.EntitlementTemplate.AsStatic()
if err != nil {
return def, true, fmt.Errorf("failed to get static entitlement template: %w", err)
}
scheduleInput.Metadata = tpl.Metadata
var configJSON string
err = json.Unmarshal(tpl.Config, &configJSON)
if err != nil {
return def, true, fmt.Errorf("failed to unmarshal static entitlement template config: %w", err)
}
scheduleInput.Config = &configJSON
case entitlement.EntitlementTypeMetered:
tpl, err := meta.EntitlementTemplate.AsMetered()
if err != nil {
return def, true, fmt.Errorf("failed to get metered entitlement template: %w", err)
}
if opts.AlignedBillingAnchor.IsZero() {
return def, true, fmt.Errorf("aligned billing anchor shouldn't be zero")
}
truncatedAnchorTime := opts.AlignedBillingAnchor.Truncate(time.Minute)
truncatedMeasureUsageFrom := opts.PhaseStart.Truncate(time.Minute)
scheduleInput.Metadata = tpl.Metadata
scheduleInput.IsSoftLimit = &tpl.IsSoftLimit
scheduleInput.IssueAfterReset = tpl.IssueAfterReset
scheduleInput.IssueAfterResetPriority = tpl.IssueAfterResetPriority
scheduleInput.PreserveOverageAtReset = tpl.PreserveOverageAtReset
rec, err := timeutil.NewRecurrenceFromISODuration(tpl.UsagePeriod, truncatedAnchorTime)
if err != nil {
return def, true, fmt.Errorf("failed to get recurrence from ISO duration: %w", err)
}
scheduleInput.UsagePeriod = lo.ToPtr(timeutil.AsTimed(func(r timeutil.Recurrence) time.Time {
return r.Anchor
})(rec))
mu := &entitlement.MeasureUsageFromInput{}
err = mu.FromTime(truncatedMeasureUsageFrom)
if err != nil {
return def, true, fmt.Errorf("failed to get measure usage from time: %w", err)
}
scheduleInput.MeasureUsageFrom = mu
default:
return def, true, fmt.Errorf("unsupported entitlement type %s", t)
}
return ScheduleSubscriptionEntitlementInput{
CreateEntitlementInputs: scheduleInput,
Customer: opts.Customer,
}, true, nil
}
func (s SubscriptionItemSpec) GetRef(subId string) SubscriptionItemRef {
return SubscriptionItemRef{
SubscriptionId: subId,
PhaseKey: s.PhaseKey,
ItemKey: s.ItemKey,
}
}
func (s *SubscriptionItemSpec) SyncAnnotations() error {
met := s.RateCard.AsMeta()
if met.EntitlementTemplate != nil && met.EntitlementTemplate.Type() == entitlement.EntitlementTypeBoolean {
count := AnnotationParser.GetBooleanEntitlementCount(s.Annotations)
if count == 0 {
if s.Annotations == nil {
s.Annotations = models.Annotations{}
}
if _, err := AnnotationParser.SetBooleanEntitlementCount(s.Annotations, 1); err != nil {
return fmt.Errorf("failed to set boolean entitlement count: %w", err)
}
}
}
return nil
}
func (s *SubscriptionItemSpec) Validate() error {
var errs []error
// TODO: if the price is usage based, we have to validate that that the feature is metered
// TODO: if the entitlement is metered, we have to validate that the feature is metered
if s.RateCard == nil {
return fmt.Errorf("rate card is required")
}
// Let's validate nested models
if err := s.RateCard.Validate(); err != nil {
errs = append(errs, models.ErrorWithComponent("rateCard", err))
}
// Billing behavior should only be present for billable items
if s.BillingBehaviorOverride.RestartBillingPeriod != nil && !s.RateCard.IsBillable() {
errs = append(errs, ErrSubscriptionItemBillingOverrideIsOnlyAllowedForBillableItems)
}
// The relative cadence should make sense
if s.ActiveFromOverrideRelativeToPhaseStart != nil && s.ActiveFromOverrideRelativeToPhaseStart.IsNegative() {
errs = append(errs, ErrSubscriptionItemActiveFromOverrideRelativeToPhaseStartIsNegative)
}
if s.ActiveToOverrideRelativeToPhaseStart != nil && s.ActiveToOverrideRelativeToPhaseStart.IsNegative() {
errs = append(errs, ErrSubscriptionItemActiveToOverrideRelativeToPhaseStartIsNegative)
}
return errors.Join(errs...)
}
// NewSpecFromPlan creates a SubscriptionSpec from a Plan and a CreateSubscriptionCustomerInput.
func NewSpecFromPlan(p Plan, c CreateSubscriptionCustomerInput) (SubscriptionSpec, error) {
spec := SubscriptionSpec{
CreateSubscriptionPlanInput: p.ToCreateSubscriptionPlanInput(),
CreateSubscriptionCustomerInput: c,
Phases: make(map[string]*SubscriptionPhaseSpec),
}
// Let's find an intelligent name by which we can refer to the plan in contextual errors
planRefName := "custom plan"
if ref := p.ToCreateSubscriptionPlanInput().Plan; ref != nil {
planRefName = fmt.Sprintf("plan %s version %d", ref.Key, ref.Version)
}
if len(p.GetPhases()) == 0 {
return spec, fmt.Errorf("%s has no phases", planRefName)
}
// Validate that the plan phases are returned in order
planPhases := p.GetPhases()
for i := range planPhases {
if i == 0 {
continue
}
if diff, err := planPhases[i].ToCreateSubscriptionPhasePlanInput().StartAfter.Subtract(planPhases[i-1].ToCreateSubscriptionPhasePlanInput().StartAfter); err != nil || diff.IsNegative() {
return spec, fmt.Errorf("phases %s and %s of %s are in the wrong order", planPhases[i].GetKey(), planPhases[i-1].GetKey(), planRefName)
}
}
for _, planPhase := range planPhases {
if _, ok := spec.Phases[planPhase.GetKey()]; ok {
return spec, fmt.Errorf("phase %s of %s is duplicated", planPhase.GetKey(), planRefName)
}
createSubscriptionPhasePlanInput := planPhase.ToCreateSubscriptionPhasePlanInput()
phase := &SubscriptionPhaseSpec{
CreateSubscriptionPhasePlanInput: createSubscriptionPhasePlanInput,
CreateSubscriptionPhaseCustomerInput: CreateSubscriptionPhaseCustomerInput{
MetadataModel: models.MetadataModel{}, // TODO: where should we source this from? inherit from PlanPhase, or Subscription?
},
ItemsByKey: make(map[string][]*SubscriptionItemSpec),
}
if len(planPhase.GetRateCards()) == 0 {
return spec, fmt.Errorf("phase %s of %s has no rate cards", phase.PhaseKey, planRefName)
}
// We expect that in a plan phase, each rate card is unique by key, so let's validate that
rcByKey := make(map[string]struct{})
for _, rateCard := range planPhase.GetRateCards() {
if _, ok := rcByKey[rateCard.GetKey()]; ok {
return spec, fmt.Errorf("rate card %s of phase %s of %s is duplicated", rateCard.GetKey(), phase.PhaseKey, planRefName)
}
rcByKey[rateCard.GetKey()] = struct{}{}
createSubscriptionItemPlanInput := rateCard.ToCreateSubscriptionItemPlanInput()
annotations := models.Annotations{}
if _, err := AnnotationParser.AddOwnerSubSystem(annotations, OwnerSubscriptionSubSystem); err != nil {
return spec, fmt.Errorf("failed to add owner system to rate card %s of phase %s of %s: %w", rateCard.GetKey(), phase.PhaseKey, planRefName, err)
}
itemSpec := SubscriptionItemSpec{
CreateSubscriptionItemInput: CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: createSubscriptionItemPlanInput,
CreateSubscriptionItemCustomerInput: CreateSubscriptionItemCustomerInput{},
Annotations: annotations,
},
}
if phase.ItemsByKey[rateCard.GetKey()] == nil {
phase.ItemsByKey[rateCard.GetKey()] = make([]*SubscriptionItemSpec, 0)
}
phase.ItemsByKey[rateCard.GetKey()] = append(phase.ItemsByKey[rateCard.GetKey()], &itemSpec)
}
spec.Phases[phase.PhaseKey] = phase
}
// Lets sync annotations for the spec
if err := spec.SyncAnnotations(); err != nil {
return spec, fmt.Errorf("failed to sync annotations: %w", err)
}
// Lets validate the spec
if err := spec.Validate(); err != nil {
return spec, fmt.Errorf("spec validation failed: %w", err)
}
return spec, nil
}
func (s *SubscriptionSpec) Apply(applies AppliesToSpec, context ApplyContext) error {
err := applies.ApplyTo(s, context)
if err != nil {
return fmt.Errorf("apply failed: %w", err)
}
if err := s.SyncAnnotations(); err != nil {
return fmt.Errorf("failed to sync annotations: %w", err)
}
return s.Validate()
}
func (s *SubscriptionSpec) ApplyMany(applieses []AppliesToSpec, aCtx ApplyContext) error {
if err := NewAggregateAppliesToSpec(applieses).ApplyTo(s, aCtx); err != nil {
return fmt.Errorf("apply failed: %w", err)
}
if err := s.Validate(); err != nil {
return fmt.Errorf("final validation failed when applying patches: %w", err)
}
return nil
}
type SpecValidationError struct {
AffectedKeys [][]string
Msg string
}
func (e *SpecValidationError) Error() string {
return e.Msg
}
// AlignmentError is an error that occurs when the spec is not aligned but we expect it to be.
type AlignmentError struct {
Inner error
}
func (e AlignmentError) Error() string {
return fmt.Sprintf("alignment error: %s", e.Inner)
}
func (e AlignmentError) Unwrap() error {
return e.Inner
}