diff --git a/openmeter/billing/invoiceline.go b/openmeter/billing/invoiceline.go index 5b39e1951e..c21518e888 100644 --- a/openmeter/billing/invoiceline.go +++ b/openmeter/billing/invoiceline.go @@ -180,7 +180,7 @@ func (i LineBase) Validate() error { if i.InvoiceAt.IsZero() { errs = append(errs, errors.New("invoice at is required")) } else if i.InvoiceAt.Before(i.Period.Start) { - errs = append(errs, errors.New("invoice at must be after period start")) + errs = append(errs, fmt.Errorf("invoice at (%s) must be after period start (%s)", i.InvoiceAt, i.Period.Start)) } if i.Name == "" { @@ -437,7 +437,7 @@ func (i Line) Validate() error { } if i.InvoiceAt.Before(i.Period.Truncate(DefaultMeterResolution).Start) { - errs = append(errs, errors.New("invoice at must be after period start")) + errs = append(errs, fmt.Errorf("invoice at (%s) must be after period start (%s)", i.InvoiceAt, i.Period.Truncate(DefaultMeterResolution).Start)) } if err := i.Discounts.Validate(); err != nil { @@ -538,7 +538,7 @@ func (i Line) ValidateUsageBased() error { } if i.DependsOnMeteredQuantity() && i.InvoiceAt.Before(i.Period.Truncate(DefaultMeterResolution).End) { - errs = append(errs, errors.New("invoice at must be after period end for usage based line")) + errs = append(errs, fmt.Errorf("invoice at (%s) must be after period end (%s) for usage based line", i.InvoiceAt, i.Period.Truncate(DefaultMeterResolution).End)) } return errors.Join(errs...) diff --git a/openmeter/billing/worker/subscription/phaseiterator.go b/openmeter/billing/worker/subscription/phaseiterator.go index e47fc3949d..2d01a535bd 100644 --- a/openmeter/billing/worker/subscription/phaseiterator.go +++ b/openmeter/billing/worker/subscription/phaseiterator.go @@ -42,31 +42,53 @@ type PhaseIterator struct { tracer trace.Tracer } -type subscriptionItemWithPeriod struct { +type subscriptionItemWithPeriods struct { subscription.SubscriptionItemView - Period billing.Period - UniqueID string - NonTruncatedPeriod billing.Period - PhaseID string - InvoiceAligned bool -} + // References + UniqueID string + PhaseID string + + // Period Information + + // ServicePeriod is the de-facto service period that the item is billed for + ServicePeriod billing.Period + // FullServicePeriod is the full service period that the item is billed for (previously nonTruncatedPeriod) + FullServicePeriod billing.Period -func (r subscriptionItemWithPeriod) IsTruncated() bool { - return !r.Period.Equal(r.NonTruncatedPeriod) + // BillingPeriod as determined by alignment and service period + BillingPeriod billing.Period } // PeriodPercentage returns the percentage of the period that is actually billed, compared to the non-truncated period // can be used to calculate prorated prices -func (r subscriptionItemWithPeriod) PeriodPercentage() alpacadecimal.Decimal { - nonTruncatedPeriodLength := int64(r.NonTruncatedPeriod.Duration()) +func (r subscriptionItemWithPeriods) PeriodPercentage() alpacadecimal.Decimal { + fullServicePeriodLength := int64(r.FullServicePeriod.Duration()) // If the period is empty, we can't calculate the percentage, so we return 1 (100%) to prevent // any proration - if nonTruncatedPeriodLength == 0 { + if fullServicePeriodLength == 0 { return alpacadecimal.NewFromInt(1) } - return alpacadecimal.NewFromInt(int64(r.Period.Duration())).Div(alpacadecimal.NewFromInt(nonTruncatedPeriodLength)) + return alpacadecimal.NewFromInt(int64(r.ServicePeriod.Duration())).Div(alpacadecimal.NewFromInt(fullServicePeriodLength)) +} + +func (r subscriptionItemWithPeriods) GetInvoiceAt() time.Time { + // Flat-fee in advance is the only case we bill in advance + if r.Spec.RateCard.AsMeta().Price.Type() == productcatalog.FlatPriceType { + flatFee, _ := r.Spec.RateCard.AsMeta().Price.AsFlat() + if flatFee.PaymentTerm == productcatalog.InAdvancePaymentTerm { + // In advance invoicing + // - cannot be before the billing period start + // - cannot be before the service period start + return lo.Latest(r.BillingPeriod.Start, r.ServicePeriod.Start) + } + } + + // All other items are invoiced after the fact, meaning + // - not before its billing period is over + // - not before its service period is over + return lo.Latest(r.ServicePeriod.End, r.BillingPeriod.End) } func NewPhaseIterator(logger *slog.Logger, tracer trace.Tracer, subs subscription.SubscriptionView, phaseKey string) (*PhaseIterator, error) { @@ -161,12 +183,12 @@ func (it *PhaseIterator) GetMinimumBillableTime() time.Time { // or after iterationEnd. // // This ensures that we always have the upcoming lines stored on the gathering invoice. -func (it *PhaseIterator) Generate(ctx context.Context, iterationEnd time.Time) ([]subscriptionItemWithPeriod, error) { - ctx, span := tracex.Start[[]subscriptionItemWithPeriod](ctx, it.tracer, "billing.worker.subscription.phaseiterator.Generate", trace.WithAttributes( +func (it *PhaseIterator) Generate(ctx context.Context, iterationEnd time.Time) ([]subscriptionItemWithPeriods, error) { + ctx, span := tracex.Start[[]subscriptionItemWithPeriods](ctx, it.tracer, "billing.worker.subscription.phaseiterator.Generate", trace.WithAttributes( attribute.String("phase_key", it.phase.Spec.PhaseKey), )) - return span.Wrap(ctx, func(ctx context.Context) ([]subscriptionItemWithPeriod, error) { + return span.Wrap(ctx, func(ctx context.Context) ([]subscriptionItemWithPeriods, error) { if it.sub.Subscription.BillablesMustAlign { return it.generateAligned(ctx, iterationEnd) } @@ -175,15 +197,15 @@ func (it *PhaseIterator) Generate(ctx context.Context, iterationEnd time.Time) ( }) } -func (it *PhaseIterator) generateAligned(ctx context.Context, iterationEnd time.Time) ([]subscriptionItemWithPeriod, error) { - ctx, span := tracex.Start[[]subscriptionItemWithPeriod](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateAligned") +func (it *PhaseIterator) generateAligned(ctx context.Context, iterationEnd time.Time) ([]subscriptionItemWithPeriods, error) { + ctx, span := tracex.Start[[]subscriptionItemWithPeriods](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateAligned") - return span.Wrap(ctx, func(ctx context.Context) ([]subscriptionItemWithPeriod, error) { + return span.Wrap(ctx, func(ctx context.Context) ([]subscriptionItemWithPeriods, error) { if !it.sub.Subscription.BillablesMustAlign { return nil, fmt.Errorf("aligned generation is not supported for non-aligned subscriptions") } - items := []subscriptionItemWithPeriod{} + items := []subscriptionItemWithPeriods{} for _, itemsByKey := range it.phase.ItemsByKey { err := slicesx.ForEachUntilWithErr( @@ -201,7 +223,7 @@ func (it *PhaseIterator) generateAligned(ctx context.Context, iterationEnd time. }) } -func (it *PhaseIterator) generateForAlignedItemVersion(ctx context.Context, item subscription.SubscriptionItemView, version int, iterationEnd time.Time, items *[]subscriptionItemWithPeriod) (bool, error) { +func (it *PhaseIterator) generateForAlignedItemVersion(ctx context.Context, item subscription.SubscriptionItemView, version int, iterationEnd time.Time, items *[]subscriptionItemWithPeriods) (bool, error) { ctx, span := tracex.Start[bool](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateForAlignedItemVersion", trace.WithAttributes( attribute.String("itemKey", item.Spec.ItemKey), attribute.Int("itemVersion", version), @@ -254,10 +276,11 @@ func (it *PhaseIterator) generateForAlignedItemVersion(ctx context.Context, item return false, err } - *items = append(*items, newItem.item) + *items = append(*items, newItem) - periodIdx = newItem.index + 1 - at = newItem.period.To + // Let's increment + periodIdx = periodIdx + 1 + at = newItem.ServicePeriod.End // We start when the item activates, then advance until either // 1. it deactivates @@ -273,8 +296,8 @@ func (it *PhaseIterator) generateForAlignedItemVersion(ctx context.Context, item } // 3. we reach the iteration end - if !at.Before(iterationEnd) && !newItem.invoiceAt.Before(iterationEnd) { - logger.DebugContext(ctx, "exiting loop due to iteration end", slog.Time("at", at), slog.Time("iterationEnd", iterationEnd), slog.Time("invoiceAt", newItem.invoiceAt)) + if !at.Before(iterationEnd) && !newItem.GetInvoiceAt().Before(iterationEnd) { + logger.DebugContext(ctx, "exiting loop due to iteration end", slog.Time("at", at), slog.Time("iterationEnd", iterationEnd), slog.Time("invoiceAt", newItem.GetInvoiceAt())) break } @@ -295,48 +318,48 @@ type generatedVersionPeriodItem struct { period timeutil.ClosedPeriod invoiceAt time.Time index int - item subscriptionItemWithPeriod + item subscriptionItemWithPeriods } -func (it *PhaseIterator) generateForAlignedItemVersionPeriod(ctx context.Context, logger *slog.Logger, item subscription.SubscriptionItemView, version int, periodIdx int, at time.Time) (generatedVersionPeriodItem, error) { - ctx, span := tracex.Start[generatedVersionPeriodItem](ctx, it.tracer, "billing.worker.subscription.phaseiterator.generateForAlignedItemVersionPeriod", trace.WithAttributes( +func (it *PhaseIterator) generateForAlignedItemVersionPeriod(ctx context.Context, logger *slog.Logger, item subscription.SubscriptionItemView, version int, periodIdx int, at time.Time) (subscriptionItemWithPeriods, error) { + ctx, 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(ctx, func(ctx context.Context) (generatedVersionPeriodItem, error) { - var empty generatedVersionPeriodItem + return span.Wrap(ctx, func(ctx context.Context) (subscriptionItemWithPeriods, error) { + var empty subscriptionItemWithPeriods + + if !it.sub.Subscription.BillablesMustAlign { + return empty, fmt.Errorf("aligned generation is not supported for non-aligned subscriptions") + } - // We start when the item activates, then advance until either - nonTruncatedPeriod, err := it.sub.Spec.GetAlignedBillingPeriodAt(it.phase.Spec.PhaseKey, at) + billingPeriod, err := it.sub.Spec.GetAlignedBillingPeriodAt(it.phase.Spec.PhaseKey, at) if err != nil { logger.ErrorContext(ctx, "failed to get aligned billing period", slog.Any("error", err)) return empty, err } - // Period otherwise is truncated by activeFrom and activeTo times - period := timeutil.ClosedPeriod{ - From: nonTruncatedPeriod.From, - To: nonTruncatedPeriod.To, - } - - if item.SubscriptionItem.ActiveFrom.After(period.From) { - period.From = item.SubscriptionItem.ActiveFrom + fullServicePeriod, err := item.Spec.GetFullServicePeriodAt( + it.phaseCadence, + item.SubscriptionItem.CadencedModel, + at, + &billingPeriod.From, // We can use the billing period start as that's already aligned + ) + if err != nil { + logger.ErrorContext(ctx, "failed to get full service period", slog.Any("error", err)) + return empty, err } - if item.SubscriptionItem.ActiveTo != nil && item.SubscriptionItem.ActiveTo.Before(period.To) { - period.To = *item.SubscriptionItem.ActiveTo + servicePeriod, err := fullServicePeriod.Open().Intersection(item.SubscriptionItem.CadencedModel.AsPeriod()).Closed() + if err != nil { + logger.ErrorContext(ctx, "failed to get service period", slog.Any("error", err)) + return empty, err } // Let's build the line - generatedItem := subscriptionItemWithPeriod{ + generatedItem := subscriptionItemWithPeriods{ SubscriptionItemView: item, - InvoiceAligned: true, - - Period: billing.Period{ - Start: period.From, - End: period.To, - }, UniqueID: strings.Join([]string{ it.sub.Subscription.ID, @@ -345,30 +368,28 @@ func (it *PhaseIterator) generateForAlignedItemVersionPeriod(ctx context.Context fmt.Sprintf("v[%d]", version), fmt.Sprintf("period[%d]", periodIdx), }, "/"), - - NonTruncatedPeriod: billing.Period{ - Start: nonTruncatedPeriod.From, - End: nonTruncatedPeriod.To, - }, PhaseID: it.phase.SubscriptionPhase.ID, - } - invoiceAt, err := it.getInvoiceAt(period, *item.SubscriptionItem.RateCard.AsMeta().Price) - if err != nil { - return empty, err + ServicePeriod: billing.Period{ + Start: servicePeriod.From, + End: servicePeriod.To, + }, + FullServicePeriod: billing.Period{ + Start: fullServicePeriod.From, + End: fullServicePeriod.To, + }, + BillingPeriod: billing.Period{ + Start: billingPeriod.From, + End: billingPeriod.To, + }, } - return generatedVersionPeriodItem{ - period: period, - invoiceAt: invoiceAt, - index: periodIdx, - item: generatedItem, - }, nil + return generatedItem, nil }) } -func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWithPeriod, error) { - out := []subscriptionItemWithPeriod{} +func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWithPeriods, error) { + out := []subscriptionItemWithPeriods{} for _, itemsByKey := range it.phase.ItemsByKey { slices.SortFunc(itemsByKey, func(i, j subscription.SubscriptionItemView) int { return timeutil.Compare(i.SubscriptionItem.ActiveFrom, j.SubscriptionItem.ActiveFrom) @@ -376,11 +397,14 @@ func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWit for versionID, item := range itemsByKey { // Let's drop non-billable items - if item.Spec.RateCard.AsMeta().Price == nil { + if !item.Spec.RateCard.IsBillable() { continue } - price := *item.Spec.RateCard.AsMeta().Price + price := item.Spec.RateCard.AsMeta().Price + if price == nil { + return nil, fmt.Errorf("item %s should have price", item.Spec.ItemKey) + } if item.Spec.RateCard.GetBillingCadence() == nil { generatedItem, err := it.generateOneTimeItem(item, versionID) @@ -401,33 +425,30 @@ func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWit periodID := 0 for { - // Should not happen here - bCad := item.Spec.RateCard.GetBillingCadence() - if bCad == nil { - return nil, fmt.Errorf("no billing cadence found for item %s", item.Spec.ItemKey) - } - - end, _ := bCad.AddTo(start) - - nonTruncatedPeriod := billing.Period{ - Start: start, - End: end, + itemCadence := item.SubscriptionItem.CadencedModel + fullServicePeriod, err := item.Spec.GetFullServicePeriodAt( + it.phaseCadence, + itemCadence, + start, + nil, + ) + if err != nil { + return nil, err } - if item.SubscriptionItem.ActiveTo != nil && item.SubscriptionItem.ActiveTo.Before(end) { - end = *item.SubscriptionItem.ActiveTo + servicePeriod, err := fullServicePeriod.Open().Intersection(itemCadence.AsPeriod()).Closed() + if err != nil { + return nil, fmt.Errorf("failed to get service period: %w", err) } - if it.phaseCadence.ActiveTo != nil && end.After(*it.phaseCadence.ActiveTo) { - end = *it.phaseCadence.ActiveTo + // As billing is not aligned, we'll simply bill by the full service period, cut short by phase cadence + billingPeriod := fullServicePeriod + if it.phaseCadence.ActiveTo != nil && billingPeriod.To.After(*it.phaseCadence.ActiveTo) { + billingPeriod.To = *it.phaseCadence.ActiveTo } - generatedItem := subscriptionItemWithPeriod{ + generatedItem := subscriptionItemWithPeriods{ SubscriptionItemView: item, - Period: billing.Period{ - Start: start, - End: end, - }, UniqueID: strings.Join([]string{ it.sub.Subscription.ID, @@ -436,23 +457,27 @@ func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWit fmt.Sprintf("v[%d]", versionID), fmt.Sprintf("period[%d]", periodID), }, "/"), + PhaseID: it.phase.SubscriptionPhase.ID, - NonTruncatedPeriod: nonTruncatedPeriod, - PhaseID: it.phase.SubscriptionPhase.ID, + FullServicePeriod: billing.Period{ + Start: fullServicePeriod.From, + End: fullServicePeriod.To, + }, + ServicePeriod: billing.Period{ + Start: servicePeriod.From, + End: servicePeriod.To, + }, + + BillingPeriod: billing.Period{ + Start: billingPeriod.From, + End: billingPeriod.To, + }, } out = append(out, generatedItem) - invoiceAt, err := it.getInvoiceAt(timeutil.ClosedPeriod{ - From: start, - To: end, - }, price) - if err != nil { - return nil, err - } - periodID++ - start = end + start = servicePeriod.To // Either we have reached the end of the phase if it.phaseCadence.ActiveTo != nil && !start.Before(*it.phaseCadence.ActiveTo) { @@ -465,7 +490,7 @@ func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWit } // Or we have reached the iteration end - if !start.Before(iterationEnd) && !invoiceAt.Before(iterationEnd) { + if !start.Before(iterationEnd) && !generatedItem.GetInvoiceAt().Before(iterationEnd) { break } } @@ -475,8 +500,8 @@ func (it *PhaseIterator) generate(iterationEnd time.Time) ([]subscriptionItemWit return it.truncateItemsIfNeeded(out), nil } -func (it *PhaseIterator) truncateItemsIfNeeded(in []subscriptionItemWithPeriod) []subscriptionItemWithPeriod { - out := make([]subscriptionItemWithPeriod, 0, len(in)) +func (it *PhaseIterator) truncateItemsIfNeeded(in []subscriptionItemWithPeriods) []subscriptionItemWithPeriods { + out := make([]subscriptionItemWithPeriods, 0, len(in)) // We need to sanitize the output to compensate for the 1min resolution of meters for _, item := range in { // We only need to sanitize the items that are not flat priced, flat prices can be handled in any resolution @@ -485,75 +510,65 @@ func (it *PhaseIterator) truncateItemsIfNeeded(in []subscriptionItemWithPeriod) continue } - item.Period = item.Period.Truncate(billing.DefaultMeterResolution) - if item.Period.IsEmpty() { + // We truncate the service period to the meter resolution + item.ServicePeriod = item.ServicePeriod.Truncate(billing.DefaultMeterResolution) + if item.ServicePeriod.IsEmpty() { continue } - item.NonTruncatedPeriod = item.NonTruncatedPeriod.Truncate(billing.DefaultMeterResolution) - out = append(out, item) } return out } -func (it *PhaseIterator) generateOneTimeAlignedItem(item subscription.SubscriptionItemView, versionID int) (*subscriptionItemWithPeriod, error) { +func (it *PhaseIterator) generateOneTimeAlignedItem(item subscription.SubscriptionItemView, versionID int) (*subscriptionItemWithPeriods, error) { + if !it.sub.Subscription.BillablesMustAlign { + return nil, fmt.Errorf("aligned generation is not supported for non-aligned subscriptions") + } + if item.Spec.RateCard.AsMeta().Price == nil { return nil, nil } - alignedPeriod, err := it.sub.Spec.GetAlignedBillingPeriodAt(it.phase.Spec.PhaseKey, item.SubscriptionItem.ActiveFrom) - if err != nil { - // If there isn't a period to align with, we generate a simple oneTime item - return it.generateOneTimeItem(item, versionID) - } + itemCadence := item.SubscriptionItem.CadencedModel - nonTruncatedPeriod := billing.Period{ - Start: alignedPeriod.From, - End: alignedPeriod.To, + billingPeriod, err := it.sub.Spec.GetAlignedBillingPeriodAt(it.phase.Spec.PhaseKey, itemCadence.ActiveFrom) + if err != nil { + return nil, fmt.Errorf("failed to get aligned billing period at %s: %w", itemCadence.ActiveFrom, err) } - period := billing.Period{ - Start: item.SubscriptionItem.ActiveFrom, + fullServicePeriod, err := item.Spec.GetFullServicePeriodAt( + it.phaseCadence, + itemCadence, + itemCadence.ActiveFrom, + &billingPeriod.From, // we can just use the billing period start as that's already aligned + ) + if err != nil { + return nil, fmt.Errorf("failed to get full service period at %s: %w", item.SubscriptionItem.ActiveFrom, err) } - end := lo.CoalesceOrEmpty(item.SubscriptionItem.ActiveTo, it.phaseCadence.ActiveTo) - if end == nil { - // One time items are not usage based, so the price object will be a flat price - price := item.SubscriptionItem.RateCard.AsMeta().Price - - if price == nil { - // If an item has no price it is not in scope for line generation - return nil, nil - } - - if price.Type() != productcatalog.FlatPriceType { - return nil, fmt.Errorf("cannot determine period end for one-time item %s", item.Spec.ItemKey) - } + // The service period is the intersection of the full service period and the item cadence + // As fullServicePeriod is a closed period, this intersection will always have both start and end (be closed) + servicePeriodOpen := fullServicePeriod.Open().Intersection(itemCadence.AsPeriod()) - flatFee, err := price.AsFlat() - if err != nil { - return nil, err - } + if servicePeriodOpen == nil && fullServicePeriod.Duration() == time.Duration(0) { + // If the service period is an instant, we'll bill at the same time as the service period + servicePeriodOpen = lo.ToPtr(fullServicePeriod.Open()) + } - if flatFee.PaymentTerm == productcatalog.InArrearsPaymentTerm { - // If the item is InArrears but we cannot determine when that time is, let's just skip this item until we - // can determine the end of period - return nil, nil - } + 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) + } - // For in-advance fees we just specify an empty period, which is fine for non UBP items - period.End = item.SubscriptionItem.ActiveFrom - } else { - period.End = *end + servicePeriod, err := servicePeriodOpen.Closed() + if err != nil { + return nil, fmt.Errorf("failed to get service period: %w", err) } - return &subscriptionItemWithPeriod{ - InvoiceAligned: true, + return &subscriptionItemWithPeriods{ SubscriptionItemView: item, - Period: period, - NonTruncatedPeriod: nonTruncatedPeriod, + UniqueID: strings.Join([]string{ it.sub.Subscription.ID, it.phase.Spec.PhaseKey, @@ -561,49 +576,57 @@ func (it *PhaseIterator) generateOneTimeAlignedItem(item subscription.Subscripti fmt.Sprintf("v[%d]", versionID), }, "/"), PhaseID: it.phase.SubscriptionPhase.ID, + + ServicePeriod: billing.Period{ + Start: servicePeriod.From, + End: servicePeriod.To, + }, + FullServicePeriod: billing.Period{ + Start: fullServicePeriod.From, + End: fullServicePeriod.To, + }, + BillingPeriod: billing.Period{ + Start: billingPeriod.From, + End: billingPeriod.To, + }, }, nil } -func (it *PhaseIterator) generateOneTimeItem(item subscription.SubscriptionItemView, versionID int) (*subscriptionItemWithPeriod, error) { - period := billing.Period{ - Start: item.SubscriptionItem.ActiveFrom, - } - - end := lo.CoalesceOrEmpty(item.SubscriptionItem.ActiveTo, it.phaseCadence.ActiveTo) - if end == nil { - // One time items are not usage based, so the price object will be a flat price - price := item.SubscriptionItem.RateCard.AsMeta().Price +func (it *PhaseIterator) generateOneTimeItem(item subscription.SubscriptionItemView, versionID int) (*subscriptionItemWithPeriods, error) { + itemCadence := item.SubscriptionItem.CadencedModel - if price == nil { - // If an item has no price it is not in scope for line generation - return nil, nil - } + fullServicePeriod, err := item.Spec.GetFullServicePeriodAt( + it.phaseCadence, + itemCadence, + item.SubscriptionItem.ActiveFrom, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to get full service period: %w", err) + } - if price.Type() != productcatalog.FlatPriceType { - return nil, fmt.Errorf("cannot determine period end for one-time item %s", item.Spec.ItemKey) - } + servicePeriodOpen := fullServicePeriod.Open().Intersection(itemCadence.AsPeriod()) - flatFee, err := price.AsFlat() - if err != nil { - return nil, err - } + if servicePeriodOpen == nil && fullServicePeriod.Duration() == time.Duration(0) { + // If the service period is an instant, we'll bill at the same time as the service period + servicePeriodOpen = lo.ToPtr(fullServicePeriod.Open()) + } - if flatFee.PaymentTerm == productcatalog.InArrearsPaymentTerm { - // If the item is InArrears but we cannot determine when that time is, let's just skip this item until we - // can determine the end of period - return nil, nil - } + 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) + } - // For in-advance fees we just specify an empty period, which is fine for non UBP items - period.End = item.SubscriptionItem.ActiveFrom - } else { - period.End = *end + servicePeriod, err := servicePeriodOpen.Closed() + if err != nil { + return nil, fmt.Errorf("failed to get service period: %w", err) } - return &subscriptionItemWithPeriod{ + // As this is a one-time item, the billing period is the same as the full service period + billingPeriod := fullServicePeriod + + return &subscriptionItemWithPeriods{ SubscriptionItemView: item, - Period: period, - NonTruncatedPeriod: period, + UniqueID: strings.Join([]string{ it.sub.Subscription.ID, it.phase.Spec.PhaseKey, @@ -611,22 +634,18 @@ func (it *PhaseIterator) generateOneTimeItem(item subscription.SubscriptionItemV fmt.Sprintf("v[%d]", versionID), }, "/"), PhaseID: it.phase.SubscriptionPhase.ID, - }, nil -} -func (it *PhaseIterator) getInvoiceAt(period timeutil.ClosedPeriod, price productcatalog.Price) (time.Time, error) { - invoiceAt := period.To - - // Calculate the expected invoice at time: we only have one in-advance item: flat fees - if price.Type() == productcatalog.FlatPriceType { - flatFee, err := price.AsFlat() - if err != nil { - return time.Time{}, err - } - - if flatFee.PaymentTerm == productcatalog.InAdvancePaymentTerm { - invoiceAt = period.From - } - } - return invoiceAt, nil + FullServicePeriod: billing.Period{ + Start: fullServicePeriod.From, + End: fullServicePeriod.To, + }, + ServicePeriod: billing.Period{ + Start: servicePeriod.From, + End: servicePeriod.To, + }, + BillingPeriod: billing.Period{ + Start: billingPeriod.From, + End: billingPeriod.To, + }, + }, nil } diff --git a/openmeter/billing/worker/subscription/phaseiterator_test.go b/openmeter/billing/worker/subscription/phaseiterator_test.go index e299f4bbe6..1b5e7a0514 100644 --- a/openmeter/billing/worker/subscription/phaseiterator_test.go +++ b/openmeter/billing/worker/subscription/phaseiterator_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/suite" "go.opentelemetry.io/otel/trace/noop" + "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/isodate" @@ -34,11 +35,10 @@ func (s *PhaseIteratorTestSuite) SetupSuite() { } type expectedIterations struct { - Start time.Time - End time.Time + ServicePeriod billing.Period + FullServicePeriod billing.Period + BillingPeriod billing.Period Key string - NonTruncatedStart time.Time - NonTruncatedEnd time.Time } type subscriptionItemViewMock struct { @@ -59,14 +59,14 @@ func (s *PhaseIteratorTestSuite) mustParseTime(t string) time.Time { func (s *PhaseIteratorTestSuite) TestPhaseIterator() { tcs := []struct { - name string - items []subscriptionItemViewMock - end time.Time - expected []expectedIterations - expectedErr error - phaseEnd *time.Time - subscriptionEnd *time.Time - alignedSub bool + name string + items []subscriptionItemViewMock + end time.Time + expected []expectedIterations + expectedErr error + phaseEnd *time.Time + subscriptionEnd *time.Time + alignedBillingCadence isodate.Period }{ { name: "unaligned empty", @@ -75,12 +75,15 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { expected: []expectedIterations{}, }, { - name: "aligned empty", - items: []subscriptionItemViewMock{}, - alignedSub: true, - end: s.mustParseTime("2021-01-01T00:00:00Z"), - expected: []expectedIterations{}, + name: "aligned empty", + items: []subscriptionItemViewMock{}, + alignedBillingCadence: isodate.MustParse(s.T(), "P1M"), + end: s.mustParseTime("2021-01-01T00:00:00Z"), + expected: []expectedIterations{}, }, + // + // Non-Aligned Subscription Tests + // { name: "sanity", items: []subscriptionItemViewMock{ @@ -92,14 +95,34 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, }, }, @@ -118,14 +141,34 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, }, }, @@ -140,15 +183,34 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-02T15:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", - NonTruncatedEnd: s.mustParseTime("2021-01-03T00:00:00Z"), + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-02T15:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-02T15:00:00Z"), // billing period can never reach over phases + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, }, phaseEnd: lo.ToPtr(s.mustParseTime("2021-01-02T15:00:00Z")), @@ -167,36 +229,88 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { }, end: s.mustParseTime("2021-01-04T00:00:00Z"), expected: []expectedIterations{ + // 1d cadence { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key-1d/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key-1d/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key-1d/v[0]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key-1d/v[0]/period[1]", }, { - Start: s.mustParseTime("2021-01-03T00:00:00Z"), - End: s.mustParseTime("2021-01-04T00:00:00Z"), - Key: "subID/phase-test/item-key-1d/v[0]/period[2]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + Key: "subID/phase-test/item-key-1d/v[0]/period[2]", }, + // 2d cadence { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key-2d/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key-2d/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-03T00:00:00Z"), - End: s.mustParseTime("2021-01-05T00:00:00Z"), - Key: "subID/phase-test/item-key-2d/v[0]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-05T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-05T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-05T00:00:00Z"), + }, + Key: "subID/phase-test/item-key-2d/v[0]/period[1]", }, }, }, { // Note: this happens on subscription updates, but the active to/from is always disjunct - name: "active-from-to-matching-period", + name: "new-version-split-aligned-with-regular-cadence", items: []subscriptionItemViewMock{ { Key: "item-key", @@ -212,14 +326,34 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[1]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[1]/period[0]", }, }, }, @@ -240,20 +374,49 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-02T20:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", - NonTruncatedEnd: s.mustParseTime("2021-01-03T00:00:00Z"), + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-02T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, { - Start: s.mustParseTime("2021-01-02T20:00:00Z"), - End: s.mustParseTime("2021-01-03T20:00:00Z"), - Key: "subID/phase-test/item-key/v[1]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[1]/period[0]", }, }, }, @@ -290,25 +453,57 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-02T20:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", - NonTruncatedEnd: s.mustParseTime("2021-01-03T00:00:00Z"), + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-02T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + // billing period will follow the otherwise cadence + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, + // 0 length service period items are dropped { - Start: s.mustParseTime("2021-01-02T20:00:00Z"), - End: s.mustParseTime("2021-01-03T20:00:00Z"), - Key: "subID/phase-test/item-key/v[3]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + // We only truncate the service period to the meter resolution + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:04Z"), + End: s.mustParseTime("2021-01-03T20:00:04Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:04Z"), + End: s.mustParseTime("2021-01-03T20:00:04Z"), + }, + Key: "subID/phase-test/item-key/v[3]/period[0]", }, }, }, { - name: "unaligned flat-fee recurring", + name: "flat-fee recurring", items: []subscriptionItemViewMock{ { Key: "item-key", @@ -319,20 +514,50 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, { // Given end is >= invoice_at only at this point - Start: s.mustParseTime("2021-01-03T00:00:00Z"), - End: s.mustParseTime("2021-01-04T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[2]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[2]", }, }, }, @@ -344,13 +569,24 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { Type: productcatalog.FlatPriceType, }, }, - end: s.mustParseTime("2021-01-03T00:00:00Z"), + end: s.mustParseTime("2021-01-03T00:00:00Z"), + // If PhaseEnd is defined, we that should be the end of the periods for the one-time item phaseEnd: lo.ToPtr(s.mustParseTime("2021-01-05T00:00:00Z")), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-05T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-05T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-05T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-05T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]", }, }, }, @@ -373,30 +609,72 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-02T20:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", - NonTruncatedEnd: s.mustParseTime("2021-01-03T00:00:00Z"), + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-02T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, { - Start: s.mustParseTime("2021-01-02T20:00:00Z"), - End: s.mustParseTime("2021-01-03T20:00:00Z"), - Key: "subID/phase-test/item-key/v[1]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T20:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[1]/period[0]", }, { - Start: s.mustParseTime("2021-01-03T20:00:00Z"), - End: s.mustParseTime("2021-01-04T20:00:00Z"), - Key: "subID/phase-test/item-key/v[1]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T20:00:00Z"), + End: s.mustParseTime("2021-01-04T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T20:00:00Z"), + End: s.mustParseTime("2021-01-04T20:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T20:00:00Z"), + End: s.mustParseTime("2021-01-04T20:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[1]/period[1]", }, }, }, + // + // Aligned Subscription Tests + // { - name: "aligned flat-fee recurring", + name: "aligned flat-fee recurring when billing cadence is same as service cadence", items: []subscriptionItemViewMock{ { Key: "item-key", @@ -411,211 +689,237 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { StartAfter: lo.ToPtr(isodate.MustParse(s.T(), "P1DT20H")), }, }, - end: s.mustParseTime("2021-01-03T00:00:00Z"), - alignedSub: true, + end: s.mustParseTime("2021-01-03T00:00:00Z"), + alignedBillingCadence: isodate.MustParse(s.T(), "P1D"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-02T20:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[1]", - NonTruncatedEnd: s.mustParseTime("2021-01-03T00:00:00Z"), + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-02T20:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, { - Start: s.mustParseTime("2021-01-02T20:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[1]/period[0]", - NonTruncatedStart: s.mustParseTime("2021-01-02T00:00:00Z"), + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T20:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[1]/period[0]", }, // Given invoiceAt should be >= end, we have an extra in advance item { - Start: s.mustParseTime("2021-01-03T00:00:00Z"), - End: s.mustParseTime("2021-01-04T00:00:00Z"), - Key: "subID/phase-test/item-key/v[1]/period[1]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[1]/period[1]", }, }, }, { - name: "aligned one-time without cadence", + name: "aligned one-time no phase end", items: []subscriptionItemViewMock{ { Key: "item-key", Type: productcatalog.FlatPriceType, }, }, - end: s.mustParseTime("2021-01-03T00:00:00Z"), - alignedSub: true, + end: s.mustParseTime("2021-01-03T00:00:00Z"), + alignedBillingCadence: isodate.MustParse(s.T(), "P1M"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-01T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]", + // If there is no phase end, the service period will be an instant + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-01T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-01T00:00:00Z"), + }, + // If there is no foreseeable end to the phase, we'll bill after a single iteration + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-02-01T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]", }, }, }, { - name: "aligned one-time with cadence", + name: "aligned one-time with phase end", items: []subscriptionItemViewMock{ { Key: "item-key", Type: productcatalog.FlatPriceType, }, - { - Key: "item-key2", - Type: productcatalog.FlatPriceType, - Cadence: "P1D", - }, }, - end: s.mustParseTime("2021-01-02T12:00:00Z"), - alignedSub: true, + end: s.mustParseTime("2021-01-03T00:00:00Z"), + phaseEnd: lo.ToPtr(s.mustParseTime("2021-02-05T00:00:00Z")), + alignedBillingCadence: isodate.MustParse(s.T(), "P1M"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-01T00:00:00Z"), - NonTruncatedEnd: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]", - }, - { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-02T00:00:00Z"), - Key: "subID/phase-test/item-key2/v[0]/period[0]", - }, - { - Start: s.mustParseTime("2021-01-02T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key2/v[0]/period[1]", - }, - { - // Included as the previous line's invoiceAt is 2021-01-02T00:00:00Z which is < end - Start: s.mustParseTime("2021-01-03T00:00:00Z"), - End: s.mustParseTime("2021-01-04T00:00:00Z"), - Key: "subID/phase-test/item-key2/v[0]/period[2]", - }, - }, - }, - { - name: "aligned subscription item is outside of subscription", - subscriptionEnd: lo.ToPtr(s.mustParseTime("2021-01-03T00:00:00Z")), - items: []subscriptionItemViewMock{ - { - Key: "item-key", - Type: productcatalog.FlatPriceType, - Cadence: "P1D", - StartAfter: lo.ToPtr(isodate.MustParse(s.T(), "P30D")), + // If there is a foreseeable phase end, the service period will account for the entire phase + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-02-05T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-02-05T00:00:00Z"), + }, + // Billing period still follows the aligned cadence + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-02-01T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]", }, }, - end: s.mustParseTime("2021-01-03T00:00:00Z"), - alignedSub: true, - expected: []expectedIterations{}, }, { - name: "aligned subscription item crosses subs cancellation date", - subscriptionEnd: lo.ToPtr(s.mustParseTime("2021-01-03T00:00:00Z")), - end: s.mustParseTime("2021-01-03T00:00:00Z"), - alignedSub: true, + name: "aligned flat fee recurring with billing cadence different than service cadence", items: []subscriptionItemViewMock{ { Key: "item-key", Type: productcatalog.FlatPriceType, - Cadence: "P1M", + Cadence: "P1D", }, }, + end: s.mustParseTime("2021-01-02T12:00:00Z"), + alignedBillingCadence: isodate.MustParse(s.T(), "P3D"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-02T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, - }, - }, - { - name: "aligned subscription item crosses phase end date", - phaseEnd: lo.ToPtr(s.mustParseTime("2021-01-03T00:00:00Z")), - end: s.mustParseTime("2021-01-03T00:00:00Z"), - alignedSub: true, - items: []subscriptionItemViewMock{ { - Key: "item-key", - Type: productcatalog.FlatPriceType, - Cadence: "P1M", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-02T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[1]", }, - }, - expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-01-03T00:00:00Z"), - Key: "subID/phase-test/item-key/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-03T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-04T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[2]", }, }, }, { - name: "aligned subscription in advance and in arreas generation rules", - alignedSub: true, + name: "aligned subscription item is outside of subscription", + subscriptionEnd: lo.ToPtr(s.mustParseTime("2021-01-03T00:00:00Z")), items: []subscriptionItemViewMock{ { - Key: "in-advance", - Type: productcatalog.FlatPriceType, - Cadence: "P1M", - }, - { - Key: "in-arreas", - Type: productcatalog.UnitPriceType, - Cadence: "P1M", - }, - }, - end: s.mustParseTime("2021-01-03T00:00:00Z"), - expected: []expectedIterations{ - { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-02-01T00:00:00Z"), - Key: "subID/phase-test/in-advance/v[0]/period[0]", - }, - { - Start: s.mustParseTime("2021-02-01T00:00:00Z"), - End: s.mustParseTime("2021-03-01T00:00:00Z"), - Key: "subID/phase-test/in-advance/v[0]/period[1]", - }, - { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-02-01T00:00:00Z"), - Key: "subID/phase-test/in-arreas/v[0]/period[0]", + Key: "item-key", + Type: productcatalog.FlatPriceType, + Cadence: "P1D", + StartAfter: lo.ToPtr(isodate.MustParse(s.T(), "P30D")), }, }, + end: s.mustParseTime("2021-01-03T00:00:00Z"), + alignedBillingCadence: isodate.MustParse(s.T(), "P1D"), + expected: []expectedIterations{}, }, { - name: "unaligned subscription in advance and in arreas generation rules", + name: "aligned subscription item crosses subs cancellation date (also phase end date)", + subscriptionEnd: lo.ToPtr(s.mustParseTime("2021-01-03T00:00:00Z")), + end: s.mustParseTime("2021-01-03T00:00:00Z"), + alignedBillingCadence: isodate.MustParse(s.T(), "P1M"), items: []subscriptionItemViewMock{ { - Key: "in-advance", + Key: "item-key", Type: productcatalog.FlatPriceType, Cadence: "P1M", }, - { - Key: "in-arreas", - Type: productcatalog.UnitPriceType, - Cadence: "P1M", - }, }, - end: s.mustParseTime("2021-01-03T00:00:00Z"), expected: []expectedIterations{ { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-02-01T00:00:00Z"), - Key: "subID/phase-test/in-advance/v[0]/period[0]", - }, - { - Start: s.mustParseTime("2021-02-01T00:00:00Z"), - End: s.mustParseTime("2021-03-01T00:00:00Z"), - Key: "subID/phase-test/in-advance/v[0]/period[1]", - }, - { - Start: s.mustParseTime("2021-01-01T00:00:00Z"), - End: s.mustParseTime("2021-02-01T00:00:00Z"), - Key: "subID/phase-test/in-arreas/v[0]/period[0]", + ServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + FullServicePeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + // The full service period wasn't served, this will still be a month long + End: s.mustParseTime("2021-02-01T00:00:00Z"), + }, + BillingPeriod: billing.Period{ + Start: s.mustParseTime("2021-01-01T00:00:00Z"), + // If the subscription ends, of course we can bill + // Also, as otherwise, cadence cannot reach cross phase boundaries, which the subscription end is + End: s.mustParseTime("2021-01-03T00:00:00Z"), + }, + Key: "subID/phase-test/item-key/v[0]/period[0]", }, }, }, @@ -710,6 +1014,22 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { spec.ActiveToOverrideRelativeToPhaseStart = item.EndAfter } + if tc.phaseEnd != nil { + if view.SubscriptionItem.ActiveTo != nil && tc.phaseEnd.Before(*view.SubscriptionItem.ActiveTo) { + view.SubscriptionItem.ActiveTo = tc.phaseEnd + } else { + view.SubscriptionItem.ActiveTo = tc.phaseEnd + } + } + + if tc.subscriptionEnd != nil { + if view.SubscriptionItem.ActiveTo != nil && tc.subscriptionEnd.Before(*view.SubscriptionItem.ActiveTo) { + view.SubscriptionItem.ActiveTo = tc.subscriptionEnd + } else { + view.SubscriptionItem.ActiveTo = tc.subscriptionEnd + } + } + if view.SubscriptionItem.ActiveFrom.IsZero() { view.SubscriptionItem.ActiveFrom = phase.SubscriptionPhase.ActiveFrom } @@ -742,15 +1062,16 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { subs.Spec.ActiveTo = tc.subscriptionEnd } - if tc.alignedSub { + if !tc.alignedBillingCadence.IsZero() { subs.Subscription.BillablesMustAlign = true subs.Spec.BillablesMustAlign = true + subs.Spec.BillingCadence = tc.alignedBillingCadence + subs.Subscription.BillingCadence = tc.alignedBillingCadence } if tc.phaseEnd != nil { subs.Spec.ActiveTo = tc.phaseEnd subs.Subscription.ActiveTo = tc.phaseEnd - // Item activity is butched here } it, err := NewPhaseIterator( @@ -772,29 +1093,18 @@ func (s *PhaseIteratorTestSuite) TestPhaseIterator() { outAsExpect := make([]expectedIterations, 0, len(out)) for i, item := range out { - nonTruncatedEnd := time.Time{} - if !item.NonTruncatedPeriod.End.Equal(item.Period.End) { - nonTruncatedEnd = item.NonTruncatedPeriod.End - } - - nonTruncatedStart := time.Time{} - if !item.NonTruncatedPeriod.Start.Equal(item.Period.Start) { - nonTruncatedStart = item.NonTruncatedPeriod.Start - } - outAsExpect = append(outAsExpect, expectedIterations{ - Start: item.Period.Start, - End: item.Period.End, Key: item.UniqueID, - NonTruncatedEnd: nonTruncatedEnd, - NonTruncatedStart: nonTruncatedStart, + ServicePeriod: item.ServicePeriod, + FullServicePeriod: item.FullServicePeriod, + BillingPeriod: item.BillingPeriod, }) - s.T().Logf("out[%d]: [%s..%s] %s (non-truncated: %s..%s)\n", i, item.Period.Start, item.Period.End, item.UniqueID, nonTruncatedStart, nonTruncatedEnd) + s.T().Logf("out[%d]: [%s..%s] %s (full-service: %s..%s) (billing: %s..%s)\n", i, item.ServicePeriod.Start, item.ServicePeriod.End, item.UniqueID, item.FullServicePeriod.Start, item.FullServicePeriod.End, item.BillingPeriod.Start, item.BillingPeriod.End) } for i, item := range tc.expected { - s.T().Logf("expected[%d]: [%s..%s] %s (non-truncated: %s..%s)\n", i, item.Start, item.End, item.Key, item.NonTruncatedStart, item.NonTruncatedEnd) + s.T().Logf("expected[%d]: [%s..%s] %s (full-service: %s..%s) (billing: %s..%s)\n", i, item.ServicePeriod.Start, item.ServicePeriod.End, item.Key, item.FullServicePeriod.Start, item.FullServicePeriod.End, item.BillingPeriod.Start, item.BillingPeriod.End) } s.ElementsMatch(tc.expected, outAsExpect) diff --git a/openmeter/billing/worker/subscription/sync.go b/openmeter/billing/worker/subscription/sync.go index 27d35878ef..1f13c145ce 100644 --- a/openmeter/billing/worker/subscription/sync.go +++ b/openmeter/billing/worker/subscription/sync.go @@ -233,13 +233,13 @@ func (h *Handler) SyncronizeSubscription(ctx context.Context, subs subscription. } type syncPlan struct { - NewSubscriptionItems []subscriptionItemWithPeriod + NewSubscriptionItems []subscriptionItemWithPeriods LinesToDelete []*billing.Line LinesToUpsert []syncPlanLineUpsert } type syncPlanLineUpsert struct { - Target subscriptionItemWithPeriod + Target subscriptionItemWithPeriods Existing *billing.Line } @@ -314,7 +314,7 @@ func (h *Handler) calculateSyncPlan(ctx context.Context, subs subscription.Subsc return nil, nil } - inScopeLinesByUniqueID, unique := slicesx.UniqueGroupBy(inScopeLines, func(i subscriptionItemWithPeriod) string { + inScopeLinesByUniqueID, unique := slicesx.UniqueGroupBy(inScopeLines, func(i subscriptionItemWithPeriods) string { return i.UniqueID }) if !unique { @@ -366,7 +366,7 @@ func (h *Handler) calculateSyncPlan(ctx context.Context, subs subscription.Subsc } return &syncPlan{ - NewSubscriptionItems: lo.Map(newLines, func(id string, _ int) subscriptionItemWithPeriod { + NewSubscriptionItems: lo.Map(newLines, func(id string, _ int) subscriptionItemWithPeriods { return inScopeLinesByUniqueID[id] }), LinesToDelete: linesToDelete, @@ -385,11 +385,11 @@ func (h *Handler) calculateSyncPlan(ctx context.Context, subs subscription.Subsc // // This approach allows us to not to have to poll all the subscriptions periodically, but we can act when an invoice is created or when // a subscription is updated. -func (h *Handler) collectUpcomingLines(ctx context.Context, subs subscription.SubscriptionView, asOf time.Time) ([]subscriptionItemWithPeriod, error) { - ctx, span := tracex.Start[[]subscriptionItemWithPeriod](ctx, h.tracer, "billing.worker.subscription.sync.collectUpcomingLines") +func (h *Handler) collectUpcomingLines(ctx context.Context, subs subscription.SubscriptionView, asOf time.Time) ([]subscriptionItemWithPeriods, error) { + ctx, span := tracex.Start[[]subscriptionItemWithPeriods](ctx, h.tracer, "billing.worker.subscription.sync.collectUpcomingLines") - return span.Wrap(ctx, func(ctx context.Context) ([]subscriptionItemWithPeriod, error) { - inScopeLines := make([]subscriptionItemWithPeriod, 0, 128) + return span.Wrap(ctx, func(ctx context.Context) ([]subscriptionItemWithPeriods, error) { + inScopeLines := make([]subscriptionItemWithPeriods, 0, 128) for _, phase := range subs.Phases { iterator, err := NewPhaseIterator(h.logger, h.tracer, subs, phase.SubscriptionPhase.Key) @@ -452,7 +452,7 @@ func (h *Handler) getDeletePatchesForLine(line *billing.Line) []linePatch { return patches } -func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView, item subscriptionItemWithPeriod, currency currencyx.Calculator) (*billing.Line, error) { +func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView, item subscriptionItemWithPeriods, currency currencyx.Calculator) (*billing.Line, error) { line := &billing.Line{ LineBase: billing.LineBase{ Namespace: subs.Subscription.Namespace, @@ -463,7 +463,8 @@ func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView Status: billing.InvoiceLineStatusValid, ChildUniqueReferenceID: &item.UniqueID, TaxConfig: item.Spec.RateCard.AsMeta().TaxConfig, - Period: item.Period, + Period: item.ServicePeriod, + InvoiceAt: item.GetInvoiceAt(), RateCardDiscounts: h.discountsToBillingDiscounts(item.Spec.RateCard.AsMeta().Discounts), Subscription: &billing.SubscriptionReference{ @@ -474,12 +475,11 @@ func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView }, } - // In advance changes should always be invoiced immediately - inAdvanceInvoiceAt := item.Period.Start - - inArrearsInvoiceAt := item.Period.End - if item.InvoiceAligned { - inArrearsInvoiceAt = item.NonTruncatedPeriod.End + // If we don't know the full service period for in-arrears items, we should wait with generating a line + if price := item.SubscriptionItem.RateCard.AsMeta().Price; price != nil && price.GetPaymentTerm() == productcatalog.InArrearsPaymentTerm { + if item.FullServicePeriod.Duration() == time.Duration(0) { + return nil, nil + } } switch item.SubscriptionItem.RateCard.AsMeta().Price.Type() { @@ -492,24 +492,10 @@ func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView // TODO[OM-1040]: We should support rounding errors in prorating calculations (such as 1/3 of a dollar is $0.33, 3*$0.33 is $0.99, if we bill // $1.00 in three equal pieces we should charge the customer $0.01 as the last split) perUnitAmount := currency.RoundToPrecision(price.Amount) - if !item.Period.IsEmpty() && h.shouldProrateFlatFee(price) { + if !item.ServicePeriod.IsEmpty() && h.shouldProrate(item, subs) { perUnitAmount = currency.RoundToPrecision(price.Amount.Mul(item.PeriodPercentage())) } - switch price.PaymentTerm { - case productcatalog.InArrearsPaymentTerm: - line.InvoiceAt = inArrearsInvoiceAt - case productcatalog.InAdvancePaymentTerm: - // In case of inAdvance we should always invoice at the start of the period and if there's a change - // prorating should void the item and credit the customer. - // - // Warning: We are not supporting voiding or crediting right now, so we are going to overcharge on - // inAdvance items in case of a change on a finalized invoice. - line.InvoiceAt = inAdvanceInvoiceAt - default: - return nil, fmt.Errorf("unsupported payment term: %v", price.PaymentTerm) - } - if perUnitAmount.IsZero() { // We don't need to bill the customer for zero amount items (zero amount items are not allowed on the lines // either, so we can safely return here) @@ -538,7 +524,6 @@ func (h *Handler) lineFromSubscritionRateCard(subs subscription.SubscriptionView } line.Type = billing.InvoiceLineTypeUsageBased - line.InvoiceAt = inArrearsInvoiceAt line.UsageBased = &billing.UsageBasedLine{ Price: item.SubscriptionItem.RateCard.AsMeta().Price, FeatureKey: *item.SubscriptionItem.RateCard.AsMeta().FeatureKey, @@ -566,19 +551,32 @@ func (h *Handler) discountsToBillingDiscounts(discounts productcatalog.Discounts return out } -func (h *Handler) shouldProrateFlatFee(price productcatalog.FlatPrice) bool { - switch price.PaymentTerm { - case productcatalog.InAdvancePaymentTerm: - return h.featureFlags.EnableFlatFeeInAdvanceProrating - case productcatalog.InArrearsPaymentTerm: - return h.featureFlags.EnableFlatFeeInArrearsProrating +func (h *Handler) shouldProrate(item subscriptionItemWithPeriods, subView subscription.SubscriptionView) bool { + if !subView.Subscription.ProRatingConfig.Enabled { + return false + } + + // We only prorate flat prices + if item.Spec.RateCard.AsMeta().Price.Type() != productcatalog.FlatPriceType { + return false + } + + // We do not prorate due to the subscription ending + if subView.Subscription.ActiveTo != nil && !subView.Subscription.ActiveTo.After(item.ServicePeriod.End) { + return false + } + + // We're just gonna prorate all flat prices based on subscription settings + switch subView.Subscription.ProRatingConfig.Mode { + case productcatalog.ProRatingModeProratePrices: + return true default: return false } } -func (h *Handler) provisionPendingLines(ctx context.Context, subs subscription.SubscriptionView, currency currencyx.Calculator, subsItems []subscriptionItemWithPeriod) error { - newLines, err := slicesx.MapWithErr(subsItems, func(subsItem subscriptionItemWithPeriod) (*billing.Line, error) { +func (h *Handler) provisionPendingLines(ctx context.Context, subs subscription.SubscriptionView, currency currencyx.Calculator, subsItems []subscriptionItemWithPeriods) error { + newLines, err := slicesx.MapWithErr(subsItems, func(subsItem subscriptionItemWithPeriods) (*billing.Line, error) { line, err := h.lineFromSubscritionRateCard(subs, subsItem, currency) if err != nil { return nil, fmt.Errorf("generating line from subscription item [%s]: %w", subsItem.SubscriptionItem.ID, err) diff --git a/openmeter/billing/worker/subscription/sync_test.go b/openmeter/billing/worker/subscription/sync_test.go index 7c4525b60f..49a179e5e2 100644 --- a/openmeter/billing/worker/subscription/sync_test.go +++ b/openmeter/billing/worker/subscription/sync_test.go @@ -663,7 +663,7 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsProrating() { s.Equal(flatFeeLine.FlatFee.Quantity.InexactFloat64(), 1.0) }) - s.Run("canceling the subscription causes the existing item to be pro-rated", func() { + s.Run("canceling the subscription DOES NOT cause the existing item to be pro-rated", func() { clock.SetTime(s.mustParseTime("2024-01-01T10:00:00Z")) cancelAt := s.mustParseTime("2024-01-01T12:00:00Z") @@ -706,8 +706,8 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsProrating() { Start: s.mustParseTime("2024-01-01T00:00:00Z"), End: cancelAt, }) - s.Equal(flatFeeLine.FlatFee.PerUnitAmount.InexactFloat64(), 2.5) - s.Equal(flatFeeLine.FlatFee.Quantity.InexactFloat64(), 1.0) + s.Equal(5.0, flatFeeLine.FlatFee.PerUnitAmount.InexactFloat64()) + s.Equal(1.0, flatFeeLine.FlatFee.Quantity.InexactFloat64()) }) } @@ -801,24 +801,44 @@ func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncNonBillableAmou // the gathering invoice will only contain both versions of the fee as we are not // doing any pro-rating logic - subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ - { - PhaseMeta: s.phaseMeta("first-phase", ""), - RateCards: productcatalog.RateCards{ - &productcatalog.UsageBasedRateCard{ - RateCardMeta: productcatalog.RateCardMeta{ - Key: "in-advance", - Name: "in-advance", - Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ - Amount: alpacadecimal.NewFromFloat(5), - PaymentTerm: productcatalog.InAdvancePaymentTerm, - }), + planInput := plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: s.Namespace, + }, + Plan: productcatalog.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Name: "Test Plan", + Key: "test-plan", + Version: 1, + Currency: currency.USD, + BillingCadence: isodate.MustParse(s.T(), "P1M"), + ProRatingConfig: productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + Phases: []productcatalog.Phase{ + { + PhaseMeta: s.phaseMeta("first-phase", ""), + RateCards: productcatalog.RateCards{ + &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "in-advance", + Name: "in-advance", + Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromFloat(5), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }), + }, + BillingCadence: isodate.MustParse(s.T(), "P1D"), + }, }, - BillingCadence: isodate.MustParse(s.T(), "P1D"), }, }, }, - }) + } + + subsView := s.createSubscriptionFromPlan(planInput) s.NoError(s.Handler.SyncronizeSubscription(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) @@ -897,24 +917,44 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsGatheringSyncNonBillableAmou // the gathering invoice will only contain both versions of the fee as we are not // doing any pro-rating logic - subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ - { - PhaseMeta: s.phaseMeta("first-phase", ""), - RateCards: productcatalog.RateCards{ - &productcatalog.UsageBasedRateCard{ - RateCardMeta: productcatalog.RateCardMeta{ - Key: "in-arrears", - Name: "in-arrears", - Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ - Amount: alpacadecimal.NewFromFloat(5), - PaymentTerm: productcatalog.InArrearsPaymentTerm, - }), + planInput := plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: s.Namespace, + }, + Plan: productcatalog.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Name: "Test Plan", + Key: "test-plan", + Version: 1, + Currency: currency.USD, + BillingCadence: isodate.MustParse(s.T(), "P1M"), + ProRatingConfig: productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + Phases: []productcatalog.Phase{ + { + PhaseMeta: s.phaseMeta("first-phase", ""), + RateCards: productcatalog.RateCards{ + &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "in-arrears", + Name: "in-arrears", + Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromFloat(5), + PaymentTerm: productcatalog.InArrearsPaymentTerm, + }), + }, + BillingCadence: isodate.MustParse(s.T(), "P1D"), + }, }, - BillingCadence: isodate.MustParse(s.T(), "P1D"), }, }, }, - }) + } + + subsView := s.createSubscriptionFromPlan(planInput) s.NoError(s.Handler.SyncronizeSubscription(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) @@ -962,7 +1002,8 @@ func (s *SubscriptionHandlerTestSuite) TestInArrearsGatheringSyncNonBillableAmou End: s.mustParseTime("2024-01-01T00:00:40Z"), }, }, - InvoiceAt: []time.Time{s.mustParseTime("2024-01-01T00:00:40Z")}, + // We'll wait till the end of the billing cadence of the item + InvoiceAt: []time.Time{s.mustParseTime("2024-01-02T00:00:00Z")}, }, { Matcher: recurringLineMatcher{ @@ -1464,9 +1505,9 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionInvoicing() { Alignment: productcatalog.Alignment{ BillablesMustAlign: true, }, - BillingCadence: isodate.MustParse(s.T(), "P2W"), + BillingCadence: isodate.MustParse(s.T(), "P4W"), ProRatingConfig: productcatalog.ProRatingConfig{ - Enabled: true, + Enabled: false, Mode: productcatalog.ProRatingModeProratePrices, }, }, @@ -1636,7 +1677,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionInvoicing() { End: s.mustParseTime("2024-01-02T00:00:00Z"), }, }, - InvoiceAt: []time.Time{s.mustParseTime("2024-01-08T00:00:00Z")}, + InvoiceAt: []time.Time{s.mustParseTime("2024-01-29T00:00:00Z")}, }, { Matcher: recurringLineMatcher{ @@ -1655,7 +1696,7 @@ func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionInvoicing() { End: s.mustParseTime("2024-01-08T00:00:00Z"), }, }, - InvoiceAt: []time.Time{s.mustParseTime("2024-01-08T00:00:00Z")}, + InvoiceAt: []time.Time{s.mustParseTime("2024-01-29T00:00:00Z")}, }, }) } @@ -3567,6 +3608,228 @@ func (s *SubscriptionHandlerTestSuite) TestUseUsageBasedFlatFeeLinesCompatibilit s.Len(linesByType[billing.InvoiceLineTypeUsageBased], 3) } +func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior() { + ctx := s.Context + clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) + defer clock.UnFreeze() + + // Given + // a subscription with two phases started, with prorating enabled + // the first phase is 2 weeks long, the second phase is unlimited + // the phases have in advance, in arrears and usage based lines + // When + // we cancel the subscription asof 2025-03-01 + // we syncronize the subscription data up to 2025-03-01 + // Then + // The in-advance and in arrears lines should be prorated for the first phase + // The usage based line's price is intact, only the period length is changed + // The second phase's lines are aligned to the phase's start (as we don't have custom anchor set) + // The second phase's in-advance and in arreas lines are not prorated (for the 2nd half period), as we only support prorating due to alignment for now + + // NOTE[implicit behavior]: Handler's prorating logic is disabled before the test execution. + + secondPhase := productcatalog.Phase{ + PhaseMeta: s.phaseMeta("second-phase", ""), + RateCards: productcatalog.RateCards{ + &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "in-advance", + Name: "in-advance", + Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromFloat(5), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }), + }, + BillingCadence: lo.ToPtr(testutils.GetISODuration(s.T(), "P1M")), + }, + &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "in-arrears", + Name: "in-arrears", + Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromFloat(5), + PaymentTerm: productcatalog.InArrearsPaymentTerm, + }), + }, + BillingCadence: lo.ToPtr(testutils.GetISODuration(s.T(), "P1M")), + }, + &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: s.APIRequestsTotalFeature.Key, + Name: s.APIRequestsTotalFeature.Key, + FeatureKey: lo.ToPtr(s.APIRequestsTotalFeature.Key), + FeatureID: lo.ToPtr(s.APIRequestsTotalFeature.ID), + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromFloat(10), + }), + }, + BillingCadence: isodate.MustParse(s.T(), "P1M"), + }, + }, + } + + firstPhase := secondPhase // Note: we are not copying the phase's rate cards, but that's fine + firstPhase.PhaseMeta = s.phaseMeta("first-phase", "P2W") + + // Let's create the initial subscription + subView := s.createSubscriptionFromPlan(plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: s.Namespace, + }, + Plan: productcatalog.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Name: "Test Plan", + Key: "test-plan", + Version: 1, + Currency: currency.USD, + Alignment: productcatalog.Alignment{ + BillablesMustAlign: true, + }, + BillingCadence: isodate.MustParse(s.T(), "P1M"), + ProRatingConfig: productcatalog.ProRatingConfig{ + Enabled: true, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + Phases: []productcatalog.Phase{ + firstPhase, + secondPhase, + }, + }, + }) + + // Let's cancel the subscription asof 2025-03-01 + clock.FreezeTime(s.mustParseTime("2024-03-01T00:00:00Z")) + _, err := s.SubscriptionService.Cancel(ctx, subView.Subscription.NamespacedID, subscription.Timing{ + Enum: lo.ToPtr(subscription.TimingImmediate), + }) + s.NoError(err) + + // Let's refetch the subscription view + subView, err = s.SubscriptionService.GetView(ctx, subView.Subscription.NamespacedID) + s.NoError(err) + + // Let's syncrhonize subscription data for 1 month + s.NoError(s.Handler.SyncronizeSubscription(ctx, subView, s.mustParseTime("2024-03-01T00:00:00Z"))) + + gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) + s.DebugDumpInvoice("gathering invoice", gatheringInvoice) + + // January is 31 days, wechange phase after 2 weeks (14 days) + // 5 * 14/31 = 2.258... which we round to 2.26 + + s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{ + // First phase lines + { + Matcher: recurringLineMatcher{ + PhaseKey: "first-phase", + ItemKey: "in-advance", + }, + Qty: mo.Some(1.0), + UnitPrice: mo.Some(2.26), + Periods: []billing.Period{ + { + Start: s.mustParseTime("2024-01-01T00:00:00Z"), + End: s.mustParseTime("2024-01-15T00:00:00Z"), + }, + }, + InvoiceAt: []time.Time{s.mustParseTime("2024-01-01T00:00:00Z")}, + }, + { + Matcher: recurringLineMatcher{ + PhaseKey: "first-phase", + ItemKey: "in-arrears", + }, + Qty: mo.Some(1.0), + UnitPrice: mo.Some(2.26), + Periods: []billing.Period{ + { + Start: s.mustParseTime("2024-01-01T00:00:00Z"), + End: s.mustParseTime("2024-01-15T00:00:00Z"), + }, + }, + InvoiceAt: []time.Time{s.mustParseTime("2024-01-15T00:00:00Z")}, + }, + { + Matcher: recurringLineMatcher{ + PhaseKey: "first-phase", + ItemKey: "api-requests-total", + }, + Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromFloat(10)})), + Periods: []billing.Period{ + { + Start: s.mustParseTime("2024-01-01T00:00:00Z"), + End: s.mustParseTime("2024-01-15T00:00:00Z"), + }, + }, + InvoiceAt: []time.Time{s.mustParseTime("2024-01-15T00:00:00Z")}, + }, + // Second phase lines + { + Matcher: recurringLineMatcher{ + PhaseKey: "second-phase", + ItemKey: "in-advance", + PeriodMin: 0, + PeriodMax: 1, + }, + Qty: mo.Some(1.0), + UnitPrice: mo.Some(5.0), + Periods: []billing.Period{ + { + Start: s.mustParseTime("2024-01-15T00:00:00Z"), + End: s.mustParseTime("2024-02-15T00:00:00Z"), + }, + { + Start: s.mustParseTime("2024-02-15T00:00:00Z"), + End: s.mustParseTime("2024-03-01T00:00:00Z"), + }, + }, + InvoiceAt: []time.Time{s.mustParseTime("2024-01-15T00:00:00Z"), s.mustParseTime("2024-02-15T00:00:00Z")}, + }, + { + Matcher: recurringLineMatcher{ + PhaseKey: "second-phase", + ItemKey: "in-arrears", + PeriodMin: 0, + PeriodMax: 1, + }, + Qty: mo.Some(1.0), + UnitPrice: mo.Some(5.0), + Periods: []billing.Period{ + { + Start: s.mustParseTime("2024-01-15T00:00:00Z"), + End: s.mustParseTime("2024-02-15T00:00:00Z"), + }, + { + Start: s.mustParseTime("2024-02-15T00:00:00Z"), + End: s.mustParseTime("2024-03-01T00:00:00Z"), + }, + }, + InvoiceAt: []time.Time{s.mustParseTime("2024-02-15T00:00:00Z"), s.mustParseTime("2024-03-01T00:00:00Z")}, + }, + { + Matcher: recurringLineMatcher{ + PhaseKey: "second-phase", + ItemKey: "api-requests-total", + PeriodMin: 0, + PeriodMax: 1, + }, + Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromFloat(10)})), + Periods: []billing.Period{ + { + Start: s.mustParseTime("2024-01-15T00:00:00Z"), + End: s.mustParseTime("2024-02-15T00:00:00Z"), + }, + { + Start: s.mustParseTime("2024-02-15T00:00:00Z"), + End: s.mustParseTime("2024-03-01T00:00:00Z"), + }, + }, + InvoiceAt: []time.Time{s.mustParseTime("2024-02-15T00:00:00Z"), s.mustParseTime("2024-03-01T00:00:00Z")}, + }, + }) +} + type expectedLine struct { Matcher lineMatcher Qty mo.Option[float64] @@ -3602,26 +3865,36 @@ func (s *SubscriptionHandlerTestSuite) expectLines(invoice billing.Invoice, subs if expectedLine.Qty.IsPresent() { if line.Type == billing.InvoiceLineTypeFee { - s.Equal(expectedLine.Qty.OrEmpty(), line.FlatFee.Quantity.InexactFloat64(), childID) + if line.FlatFee == nil { + s.Failf("flat fee line not found", "line not found with child id %s", childID) + } else { + s.Equal(expectedLine.Qty.OrEmpty(), line.FlatFee.Quantity.InexactFloat64(), "%s: quantity", childID) + } } else { - s.Equal(expectedLine.Qty.OrEmpty(), line.UsageBased.Quantity.InexactFloat64(), childID) + if line.UsageBased == nil { + s.Failf("usage based line not found", "line not found with child id %s", childID) + } else if line.UsageBased.Quantity == nil { + s.Failf("usage based line quantity not found", "line not found with child id %s", childID) + } else { + s.Equal(expectedLine.Qty.OrEmpty(), line.UsageBased.Quantity.InexactFloat64(), "%s: quantity", childID) + } } } if expectedLine.UnitPrice.IsPresent() { - s.Equal(line.Type, billing.InvoiceLineTypeFee, childID) - s.Equal(expectedLine.UnitPrice.OrEmpty(), line.FlatFee.PerUnitAmount.InexactFloat64(), childID) + s.Equal(billing.InvoiceLineTypeFee, line.Type, "%s: line type", childID) + s.Equal(expectedLine.UnitPrice.OrEmpty(), line.FlatFee.PerUnitAmount.InexactFloat64(), "%s: unit price", childID) } if expectedLine.Price.IsPresent() { - s.Equal(line.Type, billing.InvoiceLineTypeUsageBased) - s.Equal(*expectedLine.Price.OrEmpty(), *line.UsageBased.Price, childID) + s.Equal(billing.InvoiceLineTypeUsageBased, line.Type, "%s: line type", childID) + s.Equal(*expectedLine.Price.OrEmpty(), *line.UsageBased.Price, "%s: price", childID) } - s.Equal(expectedLine.Periods[idx].Start, line.Period.Start, childID) - s.Equal(expectedLine.Periods[idx].End, line.Period.End, childID) + s.Equal(expectedLine.Periods[idx].Start, line.Period.Start, "%s: period start", childID) + s.Equal(expectedLine.Periods[idx].End, line.Period.End, "%s: period end", childID) - s.Equal(expectedLine.InvoiceAt[idx], line.InvoiceAt, childID) + s.Equal(expectedLine.InvoiceAt[idx], line.InvoiceAt, "%s: invoice at", childID) } } } diff --git a/openmeter/productcatalog/alignment.go b/openmeter/productcatalog/alignment.go index 49fbff25d9..d2ee5e374c 100644 --- a/openmeter/productcatalog/alignment.go +++ b/openmeter/productcatalog/alignment.go @@ -1,5 +1,11 @@ package productcatalog +import ( + "fmt" + + "github.com/openmeterio/openmeter/pkg/isodate" +) + type Alignment struct { // BillablesMustAlign indicates whether all billable items in a given phase must share the same BillingPeriodDuration. BillablesMustAlign bool `json:"billablesMustAlign"` @@ -9,3 +15,33 @@ type Alignment struct { type AlignmentUpdate struct { BillablesMustAlign *bool `json:"billablesMustAlign,omitempty"` } + +// Alignment means that either +// - the two cadences are identical +// - if a RateCard's cadence is "longer" than the Plan's cadence, the plan cadence must "divide" without remainder the ratecard's cadence +// - if a RateCard's cadence is "shorter" than the Plan's cadence, the ratecard's cadence must "divide" without remainder the plan's cadence +// "longer" and "shorter" are not generally meaningful terms for periods, as for instance sometimes P1M equals P4W, sometimes its longer. +func ValidateBillingCadencesAlign(planBillingCadence isodate.Period, rateCardBillingCadence isodate.Period) error { + pSimple := planBillingCadence.Simplify(true) + rcSimple := rateCardBillingCadence.Simplify(true) + + // If the two cadences are identical, we're good + if pSimple.Equal(&rcSimple) { + return nil + } + + // We'll leverage the fact that Period.DibisibleBy() works correctly regardless which period is larger, + // so we'll just test both ways + + ok, err := pSimple.DivisibleBy(rcSimple) + if ok && err == nil { + return nil + } + + ok, err = rcSimple.DivisibleBy(pSimple) + if ok && err == nil { + return nil + } + + return fmt.Errorf("billing cadences do not align: %s and %s", planBillingCadence, rateCardBillingCadence) +} diff --git a/openmeter/productcatalog/errors.go b/openmeter/productcatalog/errors.go index 69d5e378d1..b4749c9873 100644 --- a/openmeter/productcatalog/errors.go +++ b/openmeter/productcatalog/errors.go @@ -393,7 +393,7 @@ const ErrCodePlanBillingCadenceInvalid models.ErrorCode = "plan_billing_cadence_ var ErrPlanBillingCadenceInvalid = models.NewValidationIssue( ErrCodePlanBillingCadenceInvalid, - "billing cadence must be at least 1 month", + "billing cadence must be at least 28 days", models.WithFieldString("billingCadence"), models.WithCriticalSeverity(), ) diff --git a/openmeter/productcatalog/plan.go b/openmeter/productcatalog/plan.go index 35f3629d91..acd6e4c768 100644 --- a/openmeter/productcatalog/plan.go +++ b/openmeter/productcatalog/plan.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/alpacahq/alpacadecimal" "github.com/invopop/gobl/currency" "github.com/samber/lo" @@ -108,8 +109,16 @@ func ValidatePlanMinimumBillingCadence() models.ValidatorFunc[Plan] { return func(p Plan) error { var errs []error - // Billing Cadence has to be at least 1 month - if p.BillingCadence.Compare(isodate.NewPeriod(0, 1, 0, 0, 0, 0, 0)) < 0 { + // Billing Cadence has to be at least 28 days + + lowestHours, err := p.BillingCadence.InHours(28) + if err != nil { + errs = append(errs, err) + } + + hoursIn28Days := alpacadecimal.NewFromInt(28 * 24) + + if lowestHours.Cmp(hoursIn28Days) < 0 { errs = append(errs, ErrPlanBillingCadenceInvalid) } @@ -144,25 +153,8 @@ func ValidatePlanHasAlignedBillingCadences() models.ValidatorFunc[Plan] { if rateCard.GetBillingCadence() != nil { rateCardBillingCadence := lo.FromPtr(rateCard.GetBillingCadence()) - switch p.BillingCadence.Compare(rateCardBillingCadence) { - case 0: - continue - case 1: - d, err := p.BillingCadence.DivisibleBy(rateCardBillingCadence) - if err != nil { - errs = append(errs, err) - } - if !d { - errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrPlanBillingCadenceNotCompatible)) - } - case -1: - d, err := rateCardBillingCadence.DivisibleBy(p.BillingCadence) - if err != nil { - errs = append(errs, err) - } - if !d { - errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrPlanBillingCadenceNotCompatible)) - } + if err := ValidateBillingCadencesAlign(p.BillingCadence, rateCardBillingCadence); err != nil { + errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, err)) } } } diff --git a/openmeter/productcatalog/price.go b/openmeter/productcatalog/price.go index edb649c73a..663ec9e1ed 100644 --- a/openmeter/productcatalog/price.go +++ b/openmeter/productcatalog/price.go @@ -77,6 +77,8 @@ type pricer interface { // Common field accessors // GetCommitments returns the commitments for the price, or an empty Commitments if the price type does not support commitments. GetCommitments() Commitments + + GetPaymentTerm() PaymentTermType } var _ pricer = (*Price)(nil) @@ -405,6 +407,16 @@ func (p *Price) GetCommitments() Commitments { } } +func (p *Price) GetPaymentTerm() PaymentTermType { + switch p.t { + case FlatPriceType: + // It's only an option for flat prices + return p.flat.PaymentTerm + } + + return InArrearsPaymentTerm +} + type FlatPrice struct { // Amount of the flat price. Amount decimal.Decimal `json:"amount"` diff --git a/openmeter/productcatalog/ratecard.go b/openmeter/productcatalog/ratecard.go index fd399991e4..ad3b10b9f9 100644 --- a/openmeter/productcatalog/ratecard.go +++ b/openmeter/productcatalog/ratecard.go @@ -38,6 +38,7 @@ type RateCard interface { Clone() RateCard Compatible(RateCard) error GetBillingCadence() *isodate.Period + IsBillable() bool } type RateCardSerde struct { @@ -223,6 +224,10 @@ func (r RateCardMeta) Validate() error { return models.NewNillableGenericValidationError(errors.Join(errs...)) } +func (r RateCardMeta) IsBillable() bool { + return r.Price != nil +} + var ( _ RateCard = (*FlatFeeRateCard)(nil) _ models.CustomValidator[*FlatFeeRateCard] = (*FlatFeeRateCard)(nil) diff --git a/openmeter/subscription/addon/extend_test.go b/openmeter/subscription/addon/extend_test.go index 85269aca52..f934aae739 100644 --- a/openmeter/subscription/addon/extend_test.go +++ b/openmeter/subscription/addon/extend_test.go @@ -877,6 +877,10 @@ type nonPointerRateCard struct{} var _ productcatalog.RateCard = nonPointerRateCard{} +func (n nonPointerRateCard) IsBillable() bool { + return true +} + func (n nonPointerRateCard) AsMeta() productcatalog.RateCardMeta { return productcatalog.RateCardMeta{} } diff --git a/openmeter/subscription/subscription.go b/openmeter/subscription/subscription.go index 3476f24133..211c5436d9 100644 --- a/openmeter/subscription/subscription.go +++ b/openmeter/subscription/subscription.go @@ -37,13 +37,15 @@ func (s Subscription) AsEntityInput() CreateSubscriptionEntityInput { NamespacedModel: models.NamespacedModel{ Namespace: s.Namespace, }, - Alignment: s.Alignment, - MetadataModel: s.MetadataModel, - Plan: s.PlanRef, - Name: s.Name, - Description: s.Description, - CustomerId: s.CustomerId, - Currency: s.Currency, + Alignment: s.Alignment, + MetadataModel: s.MetadataModel, + Plan: s.PlanRef, + Name: s.Name, + Description: s.Description, + CustomerId: s.CustomerId, + Currency: s.Currency, + BillingCadence: s.BillingCadence, + ProRatingConfig: s.ProRatingConfig, } } diff --git a/openmeter/subscription/subscriptionspec.go b/openmeter/subscription/subscriptionspec.go index f55bd1d40f..75c2463245 100644 --- a/openmeter/subscription/subscriptionspec.go +++ b/openmeter/subscription/subscriptionspec.go @@ -179,7 +179,7 @@ func (s *SubscriptionSpec) HasMeteredBillables() bool { } // 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 BillingCadence duration, but can be reanchored to the time of an edit. +// 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(phaseKey string, at time.Time) (timeutil.ClosedPeriod, error) { var def timeutil.ClosedPeriod @@ -205,10 +205,13 @@ func (s *SubscriptionSpec) GetAlignedBillingPeriodAt(phaseKey string, at time.Ti return def, fmt.Errorf("phase %s validation failed: %w", phaseKey, err) } - if !phase.HasBillables() { - return def, NoBillingPeriodError{Inner: fmt.Errorf("phase %s has no billables so it doesn't have a billing period", phaseKey)} + if s.BillingCadence.IsZero() { + return def, NoBillingPeriodError{Inner: fmt.Errorf("subscription has no billing cadence")} } + dur := s.BillingCadence + + // Reanchoring is only possible by billables billables := phase.GetBillableItemsByKey() faltBillables := lo.Flatten(lo.Values(billables)) @@ -216,15 +219,6 @@ func (s *SubscriptionSpec) GetAlignedBillingPeriodAt(phaseKey string, at time.Ti return i.RateCard.GetBillingCadence() != nil }) - if len(recurringFlatBillables) == 0 { - return def, NoBillingPeriodError{Inner: fmt.Errorf("phase %s has no recurring billables so it doesn't have a billing period", phaseKey)} - } - - dur, err := phase.GetBillingCadence() - if err != nil { - return def, fmt.Errorf("failed to get billing cadence for phase %s: %w", phaseKey, err) - } - // To find the period anchor, we need to know if any item serves as a reanchor point (RestartBillingPeriod) reanchoringItems := lo.Filter(recurringFlatBillables, func(i *SubscriptionItemSpec, _ int) bool { return i.BillingBehaviorOverride.RestartBillingPeriod != nil && *i.BillingBehaviorOverride.RestartBillingPeriod @@ -260,7 +254,7 @@ func (s *SubscriptionSpec) GetAlignedBillingPeriodAt(phaseKey string, at time.Ti } } - recurrenceOfAnchor, err := timeutil.FromISODuration(&dur, anchor) + recurrenceOfAnchor, err := timeutil.RecurrenceFromISODuration(&dur, anchor) if err != nil { return def, fmt.Errorf("failed to get recurrence from ISO duration: %w", err) } @@ -318,25 +312,8 @@ func (s *SubscriptionSpec) HasAlignedBillingCadences() (bool, error) { for _, item := range itemsByKey { rateCard := item.RateCard if rateCard.GetBillingCadence() != nil { - switch s.BillingCadence.Compare(*rateCard.GetBillingCadence()) { - case 0: - continue - case 1: - d, err := s.BillingCadence.DivisibleBy(*rateCard.GetBillingCadence()) - if err != nil { - return false, err - } - if !d { - return false, nil - } - case -1: - d, err := rateCard.GetBillingCadence().DivisibleBy(s.BillingCadence) - if err != nil { - return false, err - } - if !d { - return false, nil - } + if err := productcatalog.ValidateBillingCadencesAlign(s.BillingCadence, lo.FromPtr(rateCard.GetBillingCadence())); err != nil { + return false, err } } } @@ -459,31 +436,6 @@ func (s SubscriptionPhaseSpec) HasBillables() bool { return len(s.GetBillableItemsByKey()) > 0 } -func (s SubscriptionPhaseSpec) GetBillingCadence() (isodate.Period, error) { - var def isodate.Period - - billables := s.GetBillableItemsByKey() - - faltBillables := lo.Flatten(lo.Values(billables)) - recurringFlatBillables := lo.Filter(faltBillables, func(i *SubscriptionItemSpec, _ int) bool { - return i.RateCard.GetBillingCadence() != nil - }) - - if len(recurringFlatBillables) == 0 { - return def, fmt.Errorf("phase %s has no recurring billables", s.PhaseKey) - } - - durs := lo.Map(recurringFlatBillables, func(i *SubscriptionItemSpec, _ int) isodate.Period { - return *i.RateCard.GetBillingCadence() - }) - - if len(lo.Uniq(durs)) > 1 { - return def, fmt.Errorf("phase %s has multiple billing cadences", s.PhaseKey) - } - - return durs[0], nil -} - func (s SubscriptionPhaseSpec) SyncAnnotations() error { for _, items := range s.ItemsByKey { for idx, item := range items { @@ -727,6 +679,54 @@ func (s SubscriptionItemSpec) GetCadence(phaseCadence models.CadencedModel) mode } } +// 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( + phaseCadence models.CadencedModel, + itemCadence models.CadencedModel, + at time.Time, + alignedBillingAnchor *time.Time, +) (timeutil.ClosedPeriod, error) { + if !s.RateCard.AsMeta().IsBillable() { + return timeutil.ClosedPeriod{}, fmt.Errorf("item is not billable") + } + + if !itemCadence.IsActiveAt(at) { + return timeutil.ClosedPeriod{}, fmt.Errorf("item is not active at %s", at) + } + + if !phaseCadence.IsActiveAt(at) { + return timeutil.ClosedPeriod{}, fmt.Errorf("phase is not active at %s", at) + } + + billingCadence := s.RateCard.GetBillingCadence() + if billingCadence == nil { + end := itemCadence.ActiveFrom + + if itemCadence.ActiveTo != nil { + end = *itemCadence.ActiveTo + } + + if phaseCadence.ActiveTo != nil { + end = *phaseCadence.ActiveTo + } + + return timeutil.ClosedPeriod{ + From: itemCadence.ActiveFrom, + To: end, + }, nil + } + + billingAnchor := lo.FromPtrOr(alignedBillingAnchor, itemCadence.ActiveFrom) + + rec, err := timeutil.RecurrenceFromISODuration(billingCadence, billingAnchor) + if err != nil { + return timeutil.ClosedPeriod{}, fmt.Errorf("failed to get recurrence from ISO duration: %w", err) + } + + return rec.GetPeriodAt(at) +} + func (s SubscriptionItemSpec) ToCreateSubscriptionItemEntityInput( phaseID models.NamespacedID, phaseCadence models.CadencedModel, @@ -824,7 +824,7 @@ func (s SubscriptionItemSpec) ToScheduleSubscriptionEntitlementInput( scheduleInput.IssueAfterReset = tpl.IssueAfterReset scheduleInput.IssueAfterResetPriority = tpl.IssueAfterResetPriority scheduleInput.PreserveOverageAtReset = tpl.PreserveOverageAtReset - rec, err := timeutil.FromISODuration(&tpl.UsagePeriod, truncatedStartTime) + rec, err := timeutil.RecurrenceFromISODuration(&tpl.UsagePeriod, truncatedStartTime) if err != nil { return def, true, fmt.Errorf("failed to get recurrence from ISO duration: %w", err) } diff --git a/openmeter/subscription/subscriptionview.go b/openmeter/subscription/subscriptionview.go index ba4be1b828..551a4b49e0 100644 --- a/openmeter/subscription/subscriptionview.go +++ b/openmeter/subscription/subscriptionview.go @@ -191,7 +191,7 @@ func (s *SubscriptionItemView) Validate() error { return fmt.Errorf("entitlement %s preserveOverageAtReset does not match template preserveOverageAtReset", s.Entitlement.Entitlement.ID) } - upRec, err := timeutil.FromISODuration(&e.UsagePeriod, mEnt.UsagePeriod.Anchor) + upRec, err := timeutil.RecurrenceFromISODuration(&e.UsagePeriod, mEnt.UsagePeriod.Anchor) if err != nil { return fmt.Errorf("failed to convert Item %s EntitlementTemplate UsagePeriod ISO duration to Recurrence: %w", s.SubscriptionItem.Key, err) } diff --git a/openmeter/subscription/testutils/compare.go b/openmeter/subscription/testutils/compare.go index bc20285f0d..316cb543ca 100644 --- a/openmeter/subscription/testutils/compare.go +++ b/openmeter/subscription/testutils/compare.go @@ -107,7 +107,7 @@ func ValidateSpecAndView(t *testing.T, expected subscription.SubscriptionSpec, f require.NotNil(t, period) // Unfortunately entitlements has minute precision so it can only be aligned to the truncated minute - rec, err := timeutil.FromISODuration(period, ent.Cadence.ActiveFrom.Truncate(time.Minute)) + rec, err := timeutil.RecurrenceFromISODuration(period, ent.Cadence.ActiveFrom.Truncate(time.Minute)) up := entitlement.UsagePeriod(rec) assert.NoError(t, err) assert.Equal(t, &up, ent.Entitlement.UsagePeriod) diff --git a/pkg/isodate/date.go b/pkg/isodate/date.go index 928a3cbf28..34779a939f 100644 --- a/pkg/isodate/date.go +++ b/pkg/isodate/date.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/alpacahq/alpacadecimal" "github.com/govalues/decimal" "github.com/rickb777/period" "github.com/samber/lo" @@ -60,42 +61,57 @@ func (p Period) Simplify(exact bool) Period { } // InHours returns the value of the period in hours -func (p Period) InHours(daysInMonth int) (decimal.Decimal, error) { +func (p Period) InHours(daysInMonth int) (alpacadecimal.Decimal, error) { + zero := alpacadecimal.NewFromInt(0) + + // You might be thinking, a year is supposed to be 365 or 366 days, not 372 or 360 or 348 or 336 + // (as this below line calculates it depending on days in the month) + // Lucky for us, the method as a whole gives correct results years, err := p.Period.YearsDecimal().Mul(decimal.MustNew(int64(daysInMonth*12*24), 0)) if err != nil { - return decimal.Zero, err + return zero, err } months, err := p.Period.MonthsDecimal().Mul(decimal.MustNew(int64(daysInMonth*24), 0)) if err != nil { - return decimal.Zero, err + return zero, err } weeks, err := p.Period.WeeksDecimal().Mul(decimal.MustNew(7*24, 0)) if err != nil { - return decimal.Zero, err + return zero, err } days, err := p.Period.DaysDecimal().Mul(decimal.MustNew(24, 0)) if err != nil { - return decimal.Zero, err + return zero, err } v, err := years.Add(months) if err != nil { - return decimal.Zero, err + return zero, err } v, err = v.Add(weeks) if err != nil { - return decimal.Zero, err + return zero, err } v, err = v.Add(days) if err != nil { - return decimal.Zero, err + return zero, err } v, err = v.Add(p.Period.HoursDecimal()) if err != nil { - return decimal.Zero, err + return zero, err + } + + scale := v.MinScale() + whole, frac, ok := v.Int64(scale) + if !ok { + return zero, fmt.Errorf("failed to convert to int64") + } + + if frac != 0 { + return zero, fmt.Errorf("we shouldn't have any fractional part here") } - return v, nil + return alpacadecimal.NewFromInt(whole), nil } func (p Period) Add(p2 Period) (Period, error) { @@ -118,18 +134,12 @@ func (p Period) Subtract(p2 Period) (Period, error) { return Period{p3}, err } -// Compare returns -1 if p is less than p2, 0 if they are equal, and 1 if p is greater than p2 -func (p Period) Compare(p2 Period) int { - diff, _ := p.Period.Subtract(p2.Period) - return diff.Sign() -} - -// DivisibleBy returns true if the period is divisible by the smaller period (in days). -func (larger Period) DivisibleBy(smaller Period) (bool, error) { - l := larger.Simplify(true) +// DivisibleBy returns true if the period is divisible by the smaller period (in hours). +func (p Period) DivisibleBy(smaller Period) (bool, error) { + l := p.Simplify(true) s := smaller.Simplify(true) - if l.IsZero() || s.IsZero() || l.Compare(s) < 0 { + if l.IsZero() || s.IsZero() { return false, nil } @@ -148,7 +158,8 @@ func (larger Period) DivisibleBy(smaller Period) (bool, error) { if err != nil { return false, err } - if _, r, err := lh.QuoRem(sh); err != nil || !r.IsZero() { + + if _, r := lh.QuoRem(sh, 0); !r.IsZero() { return false, err } } diff --git a/pkg/isodate/date_test.go b/pkg/isodate/date_test.go index 24f8466d0e..c79a0e93c9 100644 --- a/pkg/isodate/date_test.go +++ b/pkg/isodate/date_test.go @@ -94,6 +94,20 @@ func TestDivisibleBy(t *testing.T) { expected: true, hasError: false, }, + { + name: "2 years divisible by 1 year", + larger: "P2Y", + smaller: "P1Y", + expected: true, + hasError: false, + }, + { + name: "2 years divisible by 2 months", + larger: "P2Y", + smaller: "P2M", + expected: true, + hasError: false, + }, { name: "1 year divisible by 1 month", larger: "P1Y", @@ -215,6 +229,20 @@ func TestDivisibleBy(t *testing.T) { expected: false, hasError: false, }, + { + name: "1 year not divisible by 5 days", + larger: "P1Y", + smaller: "P5D", + expected: false, + hasError: false, + }, + { + name: "1 year not divisible by 365 days", + larger: "P1Y", + smaller: "P365D", + expected: false, + hasError: false, + }, { name: "1 month not divisible by 5 hour", larger: "P1M", @@ -254,6 +282,20 @@ func TestDivisibleBy(t *testing.T) { expected: false, hasError: false, }, + { + name: "smaller period larger than larger period 2", + larger: "P1W", + smaller: "P1M", + expected: false, + hasError: false, + }, + { + name: "smaller period larger than larger period 2", + larger: "P4W", + smaller: "P1M", + expected: false, + hasError: false, + }, { name: "1 month smaller than 1 year", larger: "P1M", diff --git a/pkg/timeutil/closedperiod.go b/pkg/timeutil/closedperiod.go index 7e07f06107..f449500307 100644 --- a/pkg/timeutil/closedperiod.go +++ b/pkg/timeutil/closedperiod.go @@ -53,3 +53,39 @@ func (p ClosedPeriod) Overlaps(other ClosedPeriod) bool { func (p ClosedPeriod) OverlapsInclusive(other ClosedPeriod) bool { return p.ContainsInclusive(other.From) || p.ContainsInclusive(other.To) || other.ContainsInclusive(p.From) || other.ContainsInclusive(p.To) } + +func (p ClosedPeriod) Intersection(other ClosedPeriod) *ClosedPeriod { + // Calculate the latest From date (intersection starts at the later of the two start times) + var newFrom time.Time + if p.From.After(other.From) { + newFrom = p.From + } else { + newFrom = other.From + } + + // Calculate the earliest To date (intersection ends at the earlier of the two end times) + var newTo time.Time + if p.To.Before(other.To) { + newTo = p.To + } else { + newTo = other.To + } + + // Check if the periods overlap + // If the start is at or after the end, there's no overlap + if !newFrom.Before(newTo) { + return nil + } + + return &ClosedPeriod{ + From: newFrom, + To: newTo, + } +} + +func (p ClosedPeriod) Open() OpenPeriod { + return OpenPeriod{ + From: &p.From, + To: &p.To, + } +} diff --git a/pkg/timeutil/closedperiod_test.go b/pkg/timeutil/closedperiod_test.go index 89236a57c7..2f647d8d61 100644 --- a/pkg/timeutil/closedperiod_test.go +++ b/pkg/timeutil/closedperiod_test.go @@ -122,4 +122,68 @@ func TestClosedPeriod(t *testing.T) { assert.True(t, period.OverlapsInclusive(timeutil.ClosedPeriod{From: startTime.Add(time.Second), To: endTime.Add(-time.Second)})) }) }) + + t.Run("Intersection", func(t *testing.T) { + t.Run("Should return nil for non-overlapping periods", func(t *testing.T) { + // Distant periods + other := timeutil.ClosedPeriod{From: endTime.Add(time.Second), To: endTime.Add(2 * time.Second)} + assert.Nil(t, period.Intersection(other)) + + // Sequential periods (touching at boundary) + other = timeutil.ClosedPeriod{From: endTime, To: endTime.Add(time.Second)} + assert.Nil(t, period.Intersection(other)) + }) + + t.Run("Should return intersection for overlapping periods", func(t *testing.T) { + // Partial overlap from the left + other := timeutil.ClosedPeriod{From: startTime.Add(-time.Second), To: startTime.Add(30 * time.Second)} + intersection := period.Intersection(other) + assert.NotNil(t, intersection) + assert.Equal(t, startTime, intersection.From) + assert.Equal(t, startTime.Add(30*time.Second), intersection.To) + + // Partial overlap from the right + other = timeutil.ClosedPeriod{From: startTime.Add(30 * time.Second), To: endTime.Add(time.Second)} + intersection = period.Intersection(other) + assert.NotNil(t, intersection) + assert.Equal(t, startTime.Add(30*time.Second), intersection.From) + assert.Equal(t, endTime, intersection.To) + }) + + t.Run("Should return contained period when one period is inside another", func(t *testing.T) { + // Other period is contained within this period + other := timeutil.ClosedPeriod{From: startTime.Add(15 * time.Second), To: startTime.Add(45 * time.Second)} + intersection := period.Intersection(other) + assert.NotNil(t, intersection) + assert.Equal(t, other.From, intersection.From) + assert.Equal(t, other.To, intersection.To) + + // This period is contained within other period + other = timeutil.ClosedPeriod{From: startTime.Add(-time.Second), To: endTime.Add(time.Second)} + intersection = period.Intersection(other) + assert.NotNil(t, intersection) + assert.Equal(t, period.From, intersection.From) + assert.Equal(t, period.To, intersection.To) + }) + + t.Run("Should return exact period when periods are identical", func(t *testing.T) { + other := timeutil.ClosedPeriod{From: startTime, To: endTime} + intersection := period.Intersection(other) + assert.NotNil(t, intersection) + assert.Equal(t, startTime, intersection.From) + assert.Equal(t, endTime, intersection.To) + }) + + t.Run("Should handle zero-length periods", func(t *testing.T) { + // Zero-length period at start boundary + other := timeutil.ClosedPeriod{From: startTime, To: startTime} + intersection := period.Intersection(other) + assert.Nil(t, intersection) // No valid intersection since end is not after start + + // Zero-length period inside + other = timeutil.ClosedPeriod{From: startTime.Add(30 * time.Second), To: startTime.Add(30 * time.Second)} + intersection = period.Intersection(other) + assert.Nil(t, intersection) // No valid intersection since end is not after start + }) + }) } diff --git a/pkg/timeutil/openperiod.go b/pkg/timeutil/openperiod.go index 93ea271200..65d34ff9a0 100644 --- a/pkg/timeutil/openperiod.go +++ b/pkg/timeutil/openperiod.go @@ -1,6 +1,9 @@ package timeutil -import "time" +import ( + "fmt" + "time" +) type OpenPeriod struct { From *time.Time `json:"from,omitempty"` @@ -263,3 +266,14 @@ func (p OpenPeriod) IsSupersetOf(other OpenPeriod) bool { return true } + +func (p OpenPeriod) Closed() (ClosedPeriod, error) { + if p.From == nil || p.To == nil { + return ClosedPeriod{}, fmt.Errorf("cannot convert open period to closed period with nil boundaries") + } + + return ClosedPeriod{ + From: *p.From, + To: *p.To, + }, nil +} diff --git a/pkg/timeutil/recurrence.go b/pkg/timeutil/recurrence.go index d7a079757c..346cfe8eb1 100644 --- a/pkg/timeutil/recurrence.go +++ b/pkg/timeutil/recurrence.go @@ -164,7 +164,7 @@ var ( RecurrencePeriodYear RecurrenceInterval = RecurrenceInterval{isodate.NewPeriod(1, 0, 0, 0, 0, 0, 0)} ) -func FromISODuration(p *isodate.Period, anchor time.Time) (Recurrence, error) { +func RecurrenceFromISODuration(p *isodate.Period, anchor time.Time) (Recurrence, error) { if p == nil { return Recurrence{}, fmt.Errorf("period cannot be nil") } diff --git a/test/billing/subscription_test.go b/test/billing/subscription_test.go index 53ca2f8148..df7ca27f2d 100644 --- a/test/billing/subscription_test.go +++ b/test/billing/subscription_test.go @@ -116,7 +116,7 @@ func (s *SubscriptionTestSuite) TestDefaultProfileChange() { Key: "paid-plan", Version: 1, Currency: currency.USD, - BillingCadence: isodate.MustParse(s.T(), "P1D"), + BillingCadence: isodate.MustParse(s.T(), "P1M"), ProRatingConfig: productcatalog.ProRatingConfig{ Enabled: true, Mode: productcatalog.ProRatingModeProratePrices, @@ -167,7 +167,7 @@ func (s *SubscriptionTestSuite) TestDefaultProfileChange() { Key: "free-plan", Version: 1, Currency: currency.USD, - BillingCadence: isodate.MustParse(s.T(), "P1D"), + BillingCadence: isodate.MustParse(s.T(), "P1M"), ProRatingConfig: productcatalog.ProRatingConfig{ Enabled: true, Mode: productcatalog.ProRatingModeProratePrices,