| package targetstate |
|
|
| import ( |
| "context" |
| "fmt" |
| "log/slog" |
| "runtime/debug" |
| "strings" |
| "time" |
|
|
| "github.com/alpacahq/alpacadecimal" |
| "github.com/samber/lo" |
| "go.opentelemetry.io/otel/attribute" |
| "go.opentelemetry.io/otel/trace" |
|
|
| "github.com/openmeterio/openmeter/openmeter/productcatalog" |
| "github.com/openmeterio/openmeter/openmeter/streaming" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| "github.com/openmeterio/openmeter/pkg/framework/tracex" |
| "github.com/openmeterio/openmeter/pkg/models" |
| "github.com/openmeterio/openmeter/pkg/slicesx" |
| "github.com/openmeterio/openmeter/pkg/timeutil" |
| ) |
|
|
| |
| var ( |
| TimeInfinity = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC) |
| maxSafeIter = 1000 |
| ) |
|
|
| type PhaseIterator struct { |
| |
| sub subscription.SubscriptionView |
| |
| phaseCadence models.CadencedModel |
| |
| phase subscription.SubscriptionPhaseView |
|
|
| |
| logger *slog.Logger |
| tracer trace.Tracer |
| } |
|
|
| type SubscriptionItemWithPeriods struct { |
| subscription.SubscriptionItemView |
| |
| UniqueID string |
|
|
| PhaseID string |
| PhaseKey string |
|
|
| PeriodIndex int |
| ItemVersion int |
|
|
| |
|
|
| |
| ServicePeriod timeutil.ClosedPeriod |
| |
| FullServicePeriod timeutil.ClosedPeriod |
|
|
| |
| BillingPeriod timeutil.ClosedPeriod |
| } |
|
|
| |
| |
| func (r SubscriptionItemWithPeriods) PeriodPercentage() alpacadecimal.Decimal { |
| fullServicePeriodLength := int64(r.FullServicePeriod.Duration()) |
|
|
| |
| |
| if fullServicePeriodLength == 0 { |
| return alpacadecimal.NewFromInt(1) |
| } |
|
|
| return alpacadecimal.NewFromInt(int64(r.ServicePeriod.Duration())).Div(alpacadecimal.NewFromInt(fullServicePeriodLength)) |
| } |
|
|
| func (r SubscriptionItemWithPeriods) GetInvoiceAt() time.Time { |
| |
| if r.Spec.RateCard.AsMeta().Price.Type() == productcatalog.FlatPriceType { |
| flatFee, _ := r.Spec.RateCard.AsMeta().Price.AsFlat() |
| if flatFee.PaymentTerm == productcatalog.InAdvancePaymentTerm { |
| |
| |
| return r.BillingPeriod.From |
| } |
| } |
|
|
| |
| |
| |
| return lo.Latest(r.ServicePeriod.To, r.BillingPeriod.To) |
| } |
|
|
| func NewPhaseIterator(logger *slog.Logger, tracer trace.Tracer, subs subscription.SubscriptionView, phaseKey string) (*PhaseIterator, error) { |
| phase, ok := subs.GetPhaseByKey(phaseKey) |
| if !ok { |
| return nil, fmt.Errorf("phase %s not found in subscription %s", phaseKey, subs.Subscription.ID) |
| } |
|
|
| if phase == nil { |
| return nil, fmt.Errorf("unexpected nil: phase %s not found in subscription %s", phaseKey, subs.Subscription.ID) |
| } |
|
|
| phaseCadence, err := subs.Spec.GetPhaseCadence(phaseKey) |
| if err != nil { |
| return nil, fmt.Errorf("failed to calculate Cadence for phase %s: %w", phaseKey, err) |
| } |
|
|
| it := &PhaseIterator{ |
| logger: logger, |
| tracer: tracer, |
| sub: subs, |
| phase: *phase, |
| phaseCadence: phaseCadence, |
| } |
|
|
| return it, nil |
| } |
|
|
| func (it *PhaseIterator) HasInvoicableItems() bool { |
| |
| if it.phaseCadence.ActiveTo != nil && it.phaseCadence.ActiveTo.Equal(it.phaseCadence.ActiveFrom) { |
| return false |
| } |
|
|
| return it.phase.Spec.HasBillables() |
| } |
|
|
| func (it *PhaseIterator) PhaseEnd() *time.Time { |
| return it.phaseCadence.ActiveTo |
| } |
|
|
| func (it *PhaseIterator) PhaseStart() time.Time { |
| return it.phaseCadence.ActiveFrom |
| } |
|
|
| |
| |
| |
| |
| func (it *PhaseIterator) GetMinimumBillableTime() time.Time { |
| minTime := TimeInfinity |
| for _, itemsByKey := range it.phase.ItemsByKey { |
| for _, item := range itemsByKey { |
| if item.Spec.RateCard.AsMeta().Price == nil { |
| continue |
| } |
|
|
| if item.SubscriptionItem.RateCard.AsMeta().Price.Type() == productcatalog.FlatPriceType { |
| if item.SubscriptionItem.ActiveFrom.Before(minTime) { |
| minTime = item.SubscriptionItem.ActiveFrom.Truncate(streaming.MinimumWindowSizeDuration) |
| } |
| } else { |
| |
| period := timeutil.ClosedPeriod{ |
| From: item.SubscriptionItem.ActiveFrom, |
| To: TimeInfinity, |
| } |
|
|
| if item.SubscriptionItem.ActiveTo != nil { |
| period.To = *item.SubscriptionItem.ActiveTo |
| } |
|
|
| if it.phaseCadence.ActiveTo != nil && period.To.After(*it.phaseCadence.ActiveTo) { |
| period.To = *it.phaseCadence.ActiveTo |
| } |
|
|
| period = period.Truncate(streaming.MinimumWindowSizeDuration) |
| if period.IsEmpty() { |
| continue |
| } |
|
|
| if period.From.Before(minTime) { |
| minTime = period.From |
| } |
| } |
| } |
| } |
|
|
| return minTime |
| } |
|
|
| |
| |
| |
| |
| |
| func (it *PhaseIterator) Generate(ctx context.Context, iterationEnd time.Time) ([]SubscriptionItemWithPeriods, error) { |
| span := tracex.Start[[]SubscriptionItemWithPeriods](ctx, it.tracer, "billing.worker.subscription.phaseiterator.Generate", trace.WithAttributes( |
| attribute.String("phase_key", it.phase.Spec.PhaseKey), |
| )) |
|
|
| |
| iterationEnd = iterationEnd.Truncate(streaming.MinimumWindowSizeDuration).Add(streaming.MinimumWindowSizeDuration - time.Nanosecond) |
|
|
| return span.Wrap(func(ctx context.Context) ([]SubscriptionItemWithPeriods, error) { |
| return it.generateAligned(ctx, iterationEnd) |
| }) |
| } |
|
|
| func (it *PhaseIterator) generateAligned(ctx context.Context, iterationEnd time.Time) ([]SubscriptionItemWithPeriods, error) { |
| span := tracex.Start[[]SubscriptionItemWithPeriods](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateAligned") |
|
|
| return span.Wrap(func(ctx context.Context) ([]SubscriptionItemWithPeriods, error) { |
| items := []SubscriptionItemWithPeriods{} |
|
|
| for _, itemsByKey := range it.phase.ItemsByKey { |
| err := slicesx.ForEachUntilWithErr( |
| itemsByKey, |
| func(item subscription.SubscriptionItemView, version int) (breaks bool, err error) { |
| return it.generateForAlignedItemVersion(ctx, item, version, iterationEnd, &items) |
| }, |
| ) |
| if err != nil { |
| return nil, err |
| } |
| } |
|
|
| return it.truncateItemsIfNeeded(items), nil |
| }) |
| } |
|
|
| func (it *PhaseIterator) generateForAlignedItemVersion(ctx context.Context, item subscription.SubscriptionItemView, version int, iterationEnd time.Time, items *[]SubscriptionItemWithPeriods) (bool, error) { |
| span := tracex.Start[bool](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateForAlignedItemVersion", trace.WithAttributes( |
| attribute.String("itemKey", item.Spec.ItemKey), |
| attribute.Int("itemVersion", version), |
| attribute.String("phaseKey", it.phase.Spec.PhaseKey), |
| attribute.String("subscriptionId", it.sub.Subscription.ID), |
| attribute.String("phaseId", it.phase.SubscriptionPhase.ID), |
| )) |
|
|
| return span.Wrap(func(ctx context.Context) (bool, error) { |
| logger := it.logger.With( |
| "itemKey", item.Spec.ItemKey, |
| "itemVersion", version, |
| "phaseKey", it.phase.Spec.PhaseKey, |
| "subscriptionId", it.sub.Subscription.ID, |
| "phaseId", it.phase.SubscriptionPhase.ID, |
| ) |
|
|
| |
| if item.Spec.RateCard.AsMeta().Price == nil { |
| return false, nil |
| } |
|
|
| if item.Spec.RateCard.GetBillingCadence() == nil { |
| generatedItem, err := it.generateOneTimeAlignedItem(item, version) |
| if err != nil { |
| logger.ErrorContext(ctx, "failed to generate one-time aligned item", slog.Any("error", err)) |
| return false, err |
| } |
|
|
| if generatedItem == nil { |
| |
| return true, nil |
| } |
|
|
| *items = append(*items, *generatedItem) |
|
|
| return false, nil |
| } |
|
|
| periodIdx := 0 |
| at := item.SubscriptionItem.ActiveFrom |
|
|
| |
| if it.sub.Spec.ActiveTo != nil && !at.Before(*it.sub.Spec.ActiveTo) { |
| return true, nil |
| } |
|
|
| |
| if it.phaseCadence.ActiveTo != nil && !at.Before(*it.phaseCadence.ActiveTo) { |
| return true, nil |
| } |
|
|
| for { |
| logger := logger.With("periodIdx", periodIdx, "periodAt", at) |
|
|
| newItem, err := it.generateForAlignedItemVersionPeriod(ctx, logger, item, version, periodIdx, at) |
| if err != nil { |
| return false, err |
| } |
|
|
| |
| periodIdx = periodIdx + 1 |
| at = newItem.ServicePeriod.To |
|
|
| |
| if newItem.GetInvoiceAt().After(iterationEnd) { |
| logger.DebugContext(ctx, "exiting loop due to iteration end", slog.Time("at", at), slog.Time("iterationEnd", iterationEnd), slog.Time("invoiceAt", newItem.GetInvoiceAt())) |
| break |
| } |
|
|
| *items = append(*items, newItem) |
|
|
| |
| |
| if item.SubscriptionItem.ActiveTo != nil && !at.Before(*item.SubscriptionItem.ActiveTo) { |
| logger.DebugContext(ctx, "exiting loop due to item deactivation", slog.Time("at", at), slog.Time("activeTo", *item.SubscriptionItem.ActiveTo)) |
| break |
| } |
|
|
| |
| if it.phaseCadence.ActiveTo != nil && !at.Before(*it.phaseCadence.ActiveTo) { |
| logger.DebugContext(ctx, "exiting loop due to phase end", slog.Time("at", at), slog.Time("activeTo", *it.phaseCadence.ActiveTo)) |
| break |
| } |
|
|
| |
| if periodIdx > maxSafeIter { |
| logger.ErrorContext(ctx, "max iterations reached", slog.Any("iterator", it), slog.String("stack", string(debug.Stack()))) |
| break |
| } |
|
|
| logger.DebugContext(ctx, "iterating", slog.Time("at", at)) |
| } |
|
|
| return false, nil |
| }) |
| } |
|
|
| type generatedVersionPeriodItem struct { |
| period timeutil.ClosedPeriod |
| invoiceAt time.Time |
| index int |
| item SubscriptionItemWithPeriods |
| } |
|
|
| func (it *PhaseIterator) generateForAlignedItemVersionPeriod(ctx context.Context, logger *slog.Logger, item subscription.SubscriptionItemView, version int, periodIdx int, at time.Time) (SubscriptionItemWithPeriods, error) { |
| span := tracex.Start[SubscriptionItemWithPeriods](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateForAlignedItemVersionPeriod", trace.WithAttributes( |
| attribute.Int("periodIdx", periodIdx), |
| attribute.String("periodAt", at.Format(time.RFC3339)), |
| )) |
|
|
| return span.Wrap(func(ctx context.Context) (SubscriptionItemWithPeriods, error) { |
| var empty SubscriptionItemWithPeriods |
|
|
| billingPeriod, err := it.sub.Spec.GetAlignedBillingPeriodAt(at) |
| if err != nil { |
| logger.ErrorContext(ctx, "failed to get aligned billing period", slog.Any("error", err)) |
| return empty, err |
| } |
|
|
| if it.sub.Spec.BillingAnchor.IsZero() { |
| return empty, fmt.Errorf("billing anchor is zero for aligned generation, this should not happen") |
| } |
|
|
| fullServicePeriod, err := item.Spec.GetFullServicePeriodAt( |
| subscription.GetFullServicePeriodAtInput{ |
| SubscriptionCadence: it.sub.Subscription.CadencedModel, |
| PhaseCadence: it.phaseCadence, |
| ItemCadence: item.SubscriptionItem.CadencedModel, |
| At: at, |
| AlignedBillingAnchor: it.sub.Spec.BillingAnchor, |
| }, |
| ) |
| if err != nil { |
| logger.ErrorContext(ctx, "failed to get full service period", slog.Any("error", err)) |
| return empty, err |
| } |
|
|
| inter := fullServicePeriod.Open().Intersection(item.SubscriptionItem.CadencedModel.AsPeriod()) |
|
|
| |
| |
| if cl, err := item.SubscriptionItem.CadencedModel.AsPeriod().Closed(); err == nil && cl.From.Equal(cl.To) { |
| inter = lo.ToPtr(cl.Open()) |
| } |
|
|
| servicePeriod, err := inter.Closed() |
| if err != nil { |
| logger.ErrorContext(ctx, "failed to get service period", slog.Any("error", err)) |
| return empty, err |
| } |
|
|
| |
| generatedItem := SubscriptionItemWithPeriods{ |
| SubscriptionItemView: item, |
|
|
| UniqueID: strings.Join([]string{ |
| it.sub.Subscription.ID, |
| it.phase.Spec.PhaseKey, |
| item.Spec.ItemKey, |
| fmt.Sprintf("v[%d]", version), |
| fmt.Sprintf("period[%d]", periodIdx), |
| }, "/"), |
| PhaseID: it.phase.SubscriptionPhase.ID, |
| PhaseKey: it.phase.Spec.PhaseKey, |
| PeriodIndex: periodIdx, |
| ItemVersion: version, |
|
|
| ServicePeriod: timeutil.ClosedPeriod{ |
| From: servicePeriod.From, |
| To: servicePeriod.To, |
| }, |
| FullServicePeriod: timeutil.ClosedPeriod{ |
| From: fullServicePeriod.From, |
| To: fullServicePeriod.To, |
| }, |
| BillingPeriod: timeutil.ClosedPeriod{ |
| From: billingPeriod.From, |
| To: billingPeriod.To, |
| }, |
| } |
|
|
| return generatedItem, nil |
| }) |
| } |
|
|
| func (it *PhaseIterator) truncateItemsIfNeeded(in []SubscriptionItemWithPeriods) []SubscriptionItemWithPeriods { |
| out := make([]SubscriptionItemWithPeriods, 0, len(in)) |
| |
| for _, item := range in { |
| isFlatPrice := item.Spec.RateCard.AsMeta().Price != nil && item.Spec.RateCard.AsMeta().Price.Type() == productcatalog.FlatPriceType |
|
|
| |
| item.ServicePeriod = item.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) |
|
|
| |
| if item.ServicePeriod.IsEmpty() && !isFlatPrice { |
| continue |
| } |
|
|
| |
| |
| |
| item.BillingPeriod = item.BillingPeriod.Truncate(streaming.MinimumWindowSizeDuration) |
| item.FullServicePeriod = item.FullServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) |
|
|
| out = append(out, item) |
| } |
|
|
| return out |
| } |
|
|
| func (it *PhaseIterator) generateOneTimeAlignedItem(item subscription.SubscriptionItemView, versionID int) (*SubscriptionItemWithPeriods, error) { |
| if item.Spec.RateCard.AsMeta().Price == nil { |
| return nil, nil |
| } |
|
|
| itemCadence := item.SubscriptionItem.CadencedModel |
|
|
| billingPeriod, err := it.sub.Spec.GetAlignedBillingPeriodAt(itemCadence.ActiveFrom) |
| if err != nil { |
| return nil, fmt.Errorf("failed to get aligned billing period at %s: %w", itemCadence.ActiveFrom, err) |
| } |
|
|
| fullServicePeriod, err := item.Spec.GetFullServicePeriodAt( |
| subscription.GetFullServicePeriodAtInput{ |
| SubscriptionCadence: it.sub.Subscription.CadencedModel, |
| PhaseCadence: it.phaseCadence, |
| ItemCadence: itemCadence, |
| At: itemCadence.ActiveFrom, |
| AlignedBillingAnchor: billingPeriod.From, |
| }, |
| ) |
| if err != nil { |
| return nil, fmt.Errorf("failed to get full service period at %s: %w", item.SubscriptionItem.ActiveFrom, err) |
| } |
|
|
| |
| |
| servicePeriodOpen := fullServicePeriod.Open().Intersection(itemCadence.AsPeriod()) |
|
|
| if servicePeriodOpen == nil && fullServicePeriod.Duration() == time.Duration(0) { |
| |
| servicePeriodOpen = lo.ToPtr(fullServicePeriod.Open()) |
| } |
|
|
| if servicePeriodOpen == nil { |
| return nil, fmt.Errorf("service period is empty, cadence is [from %s to %s], full service period is [from %s to %s]", itemCadence.ActiveFrom, itemCadence.ActiveTo, fullServicePeriod.From, fullServicePeriod.To) |
| } |
|
|
| servicePeriod, err := servicePeriodOpen.Closed() |
| if err != nil { |
| return nil, fmt.Errorf("failed to get service period: %w", err) |
| } |
|
|
| return &SubscriptionItemWithPeriods{ |
| SubscriptionItemView: item, |
|
|
| UniqueID: strings.Join([]string{ |
| it.sub.Subscription.ID, |
| it.phase.Spec.PhaseKey, |
| item.Spec.ItemKey, |
| fmt.Sprintf("v[%d]", versionID), |
| }, "/"), |
| PhaseID: it.phase.SubscriptionPhase.ID, |
| PhaseKey: it.phase.Spec.PhaseKey, |
| PeriodIndex: 0, |
| ItemVersion: versionID, |
|
|
| ServicePeriod: timeutil.ClosedPeriod{ |
| From: servicePeriod.From, |
| To: servicePeriod.To, |
| }, |
| FullServicePeriod: timeutil.ClosedPeriod{ |
| From: fullServicePeriod.From, |
| To: fullServicePeriod.To, |
| }, |
| BillingPeriod: timeutil.ClosedPeriod{ |
| From: billingPeriod.From, |
| To: billingPeriod.To, |
| }, |
| }, nil |
| } |
|
|