| package service |
|
|
| import ( |
| "context" |
| "fmt" |
| "log/slog" |
| "slices" |
| "testing" |
| "time" |
|
|
| "github.com/invopop/gobl/currency" |
| "github.com/oklog/ulid/v2" |
| "github.com/samber/lo" |
| "github.com/samber/mo" |
| "github.com/stretchr/testify/require" |
| "go.opentelemetry.io/otel/trace/noop" |
|
|
| "github.com/openmeterio/openmeter/openmeter/billing" |
| "github.com/openmeterio/openmeter/openmeter/billing/charges" |
| "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" |
| chargesmeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" |
| chargepayment "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" |
| chargestestutils "github.com/openmeterio/openmeter/openmeter/billing/charges/testutils" |
| "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" |
| "github.com/openmeterio/openmeter/openmeter/billing/models/totals" |
| "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync" |
| "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/adapter" |
| "github.com/openmeterio/openmeter/openmeter/customer" |
| "github.com/openmeterio/openmeter/openmeter/ledger" |
| "github.com/openmeterio/openmeter/openmeter/meter" |
| "github.com/openmeterio/openmeter/openmeter/productcatalog" |
| "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" |
| "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" |
| productcatalogsubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| "github.com/openmeterio/openmeter/openmeter/subscription/patch" |
| subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" |
| "github.com/openmeterio/openmeter/openmeter/testutils" |
| "github.com/openmeterio/openmeter/pkg/clock" |
| "github.com/openmeterio/openmeter/pkg/datetime" |
| "github.com/openmeterio/openmeter/pkg/featuregate" |
| "github.com/openmeterio/openmeter/pkg/models" |
| "github.com/openmeterio/openmeter/pkg/pagination" |
| "github.com/openmeterio/openmeter/pkg/timeutil" |
| billingtest "github.com/openmeterio/openmeter/test/billing" |
| ) |
|
|
| type SuiteBase struct { |
| billingtest.BaseSuite |
| billingtest.SubscriptionMixin |
| Service *Service |
| Adapter subscriptionsync.Adapter |
| Charges charges.Service |
| Ledger ledger.Ledger |
|
|
| Namespace string |
| Customer *customer.Customer |
| APIRequestsTotalFeature feature.Feature |
| } |
|
|
| func (s *SuiteBase) SetupSuite() { |
| s.BaseSuite.SetupSuite() |
| s.SubscriptionMixin.SetupSuite(s.T(), s.GetSubscriptionMixInDependencies()) |
|
|
| adapter, err := adapter.New(adapter.Config{ |
| Client: s.DBClient, |
| }) |
| s.NoError(err) |
| s.Adapter = adapter |
|
|
| service, err := New(Config{ |
| BillingService: s.BillingService, |
| Logger: slog.Default(), |
| Tracer: noop.NewTracerProvider().Tracer("test"), |
| SubscriptionSyncAdapter: adapter, |
| SubscriptionService: s.SubscriptionService, |
| FeatureGate: featuregate.NewFeatureGateChecker(featuregate.NewNoop(), featuregate.Flags{ |
| featuregate.CtxKeyCredits: string(featuregate.CtxKeyCredits), |
| }, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}), |
| }) |
| s.NoError(err) |
|
|
| s.Service = service |
| } |
|
|
| func (s *SuiteBase) setupChargesService(config chargestestutils.Config) { |
| s.T().Helper() |
|
|
| stack, err := chargestestutils.NewServices(s.T(), config) |
| s.NoError(err) |
|
|
| s.Charges = stack.ChargesService |
|
|
| service, err := New(Config{ |
| BillingService: s.BillingService, |
| ChargesService: s.Charges, |
| Logger: slog.Default(), |
| Tracer: noop.NewTracerProvider().Tracer("test"), |
| SubscriptionSyncAdapter: s.Adapter, |
| SubscriptionService: s.SubscriptionService, |
| FeatureGate: featuregate.NewFeatureGateChecker(featuregate.NewNoop(), featuregate.Flags{ |
| featuregate.CtxKeyCredits: string(featuregate.CtxKeyCredits), |
| }, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}), |
| }) |
| s.NoError(err) |
|
|
| s.Service = service |
| } |
|
|
| func (s *SuiteBase) BeforeTest(suiteName, testName string) { |
| s.beforeTest(s.T().Context(), suiteName, testName) |
| } |
|
|
| func (s *SuiteBase) beforeTest(ctx context.Context, suiteName, testName string) { |
| s.Namespace = fmt.Sprintf("t-%s-%s-%s", suiteName, testName, ulid.Make().String()) |
|
|
| appSandbox := s.InstallSandboxApp(s.T(), s.Namespace) |
|
|
| s.ProvisionBillingProfile(ctx, s.Namespace, appSandbox.GetID()) |
| s.ProvisionDefaultTaxCodes(ctx, s.Namespace) |
|
|
| apiRequestsTotalMeterSlug := "api-requests-total" |
| apiRequestsTotalMeterID := ulid.Make().String() |
|
|
| testMeter := meter.Meter{ |
| ManagedResource: models.ManagedResource{ |
| ID: apiRequestsTotalMeterID, |
| NamespacedModel: models.NamespacedModel{ |
| Namespace: s.Namespace, |
| }, |
| ManagedModel: models.ManagedModel{ |
| CreatedAt: time.Now(), |
| UpdatedAt: time.Now(), |
| }, |
| Name: "API Requests Total", |
| }, |
| Key: apiRequestsTotalMeterSlug, |
| Aggregation: meter.MeterAggregationSum, |
| EventType: "test", |
| ValueProperty: lo.ToPtr("$.value"), |
| } |
| err := s.MeterAdapter.ReplaceMeters(ctx, []meter.Meter{testMeter}) |
| s.NoError(err, "Replacing meters must not return error") |
|
|
| apiRequestsTotalFeatureKey := "api-requests-total" |
|
|
| apiRequestsTotalFeature, err := s.FeatureService.CreateFeature(ctx, feature.CreateFeatureInputs{ |
| Namespace: s.Namespace, |
| Name: "api-requests-total", |
| Key: apiRequestsTotalFeatureKey, |
| MeterID: lo.ToPtr(apiRequestsTotalMeterID), |
| }) |
| s.NoError(err) |
| s.APIRequestsTotalFeature = apiRequestsTotalFeature |
|
|
| customerEntity := s.CreateTestCustomer(s.Namespace, "test") |
| require.NotNil(s.T(), customerEntity) |
| require.NotEmpty(s.T(), customerEntity.ID) |
|
|
| s.Customer = customerEntity |
| } |
|
|
| func (s *SuiteBase) AfterTest(suiteName, testName string) { |
| s.afterTest(s.T().Context(), suiteName, testName) |
| } |
|
|
| func (s *SuiteBase) afterTest(ctx context.Context, suiteName, testName string) { |
| clock.UnFreeze() |
| clock.ResetTime() |
|
|
| err := s.MeterAdapter.ReplaceMeters(ctx, []meter.Meter{}) |
| s.NoError(err, "Replacing meters must not return error") |
|
|
| s.MockStreamingConnector.Reset() |
| s.Service.featureFlags = FeatureFlags{} |
| } |
|
|
| func (s *SuiteBase) gatheringInvoice(ctx context.Context, namespace string, customerID string) billing.GatheringInvoice { |
| s.T().Helper() |
|
|
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Namespaces: []string{namespace}, |
| Customers: []string{customerID}, |
| Page: pagination.Page{ |
| PageSize: 10, |
| PageNumber: 1, |
| }, |
| Expand: billing.GatheringInvoiceExpands{ |
| billing.GatheringInvoiceExpandLines, |
| billing.GatheringInvoiceExpandAvailableActions, |
| }, |
| }) |
|
|
| s.NoError(err) |
| s.Len(invoices.Items, 1, "expected 1 gathering invoice") |
| return invoices.Items[0] |
| } |
|
|
| func (s *SuiteBase) expectNoGatheringInvoice(ctx context.Context, namespace string, customerID string) { |
| s.T().Helper() |
|
|
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Namespaces: []string{namespace}, |
| Customers: []string{customerID}, |
| Page: pagination.Page{ |
| PageSize: 10, |
| PageNumber: 1, |
| }, |
| Expand: billing.GatheringInvoiceExpands{}, |
| }) |
|
|
| s.NoError(err) |
| if len(invoices.Items) > 0 { |
| for _, invoice := range invoices.Items { |
| s.DebugDumpInvoice(fmt.Sprintf("unexpected gathering invoice[%s]", invoice.ID), invoice) |
| } |
| } |
| s.Len(invoices.Items, 0) |
| } |
|
|
| func (s *SuiteBase) enableProrating() { |
| s.Service.featureFlags.EnableFlatFeeInAdvanceProrating = true |
| s.Service.featureFlags.EnableFlatFeeInArrearsProrating = true |
| } |
|
|
| func (s *SuiteBase) getGatheringLineByChildID(invoice billing.GatheringInvoice, childID string) *billing.GatheringLine { |
| s.T().Helper() |
|
|
| for idx, line := range invoice.Lines.OrEmpty() { |
| if line.ChildUniqueReferenceID != nil && *line.ChildUniqueReferenceID == childID { |
| return &invoice.Lines.OrEmpty()[idx] |
| } |
| } |
|
|
| s.Failf("line not found", "line with child id %s not found", childID) |
|
|
| return nil |
| } |
|
|
| func (s *SuiteBase) getStandardLineByChildID(invoice billing.StandardInvoice, childID string) *billing.StandardLine { |
| s.T().Helper() |
|
|
| for _, line := range invoice.Lines.OrEmpty() { |
| if line.ChildUniqueReferenceID != nil && *line.ChildUniqueReferenceID == childID { |
| return line |
| } |
| } |
|
|
| s.Failf("line not found", "line with child id %s not found", childID) |
|
|
| return nil |
| } |
|
|
| func (s *SuiteBase) expectNoLineWithChildID(invoice billing.GenericInvoiceReader, childID string) { |
| s.T().Helper() |
|
|
| for _, line := range invoice.GetGenericLines().OrEmpty() { |
| if line.GetChildUniqueReferenceID() != nil && *line.GetChildUniqueReferenceID() == childID { |
| s.Failf("line found", "line with child id %s found", childID) |
| } |
| } |
| } |
|
|
| func (s *SuiteBase) timingImmediate() subscription.Timing { |
| return subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingImmediate), |
| } |
| } |
|
|
| func (s *SuiteBase) mustParseTime(t string) time.Time { |
| s.T().Helper() |
| return lo.Must(time.Parse(time.RFC3339, t)) |
| } |
|
|
| func (s *SuiteBase) testContext() context.Context { |
| s.T().Helper() |
| return s.T().Context() |
| } |
|
|
| func (s *SuiteBase) getPhaseByKey(t *testing.T, subsView subscription.SubscriptionView, key string) subscription.SubscriptionPhaseView { |
| for _, phase := range subsView.Phases { |
| if phase.SubscriptionPhase.Key == key { |
| return phase |
| } |
| } |
|
|
| t.Fatalf("phase with key %s not found", key) |
| return subscription.SubscriptionPhaseView{} |
| } |
|
|
| type expectedLine struct { |
| Matcher lineMatcher |
| Qty mo.Option[float64] |
| Price mo.Option[*productcatalog.Price] |
| Periods []timeutil.ClosedPeriod |
| InvoiceAt mo.Option[[]time.Time] |
| Charge mo.Option[chargeExpects] |
| AdditionalChecks func(line billing.GenericInvoiceLine) |
| } |
|
|
| type chargeExpects struct { |
| Status string |
| SettlementMode productcatalog.SettlementMode |
| } |
|
|
| func (s *SuiteBase) expectLines(invoice billing.GenericInvoiceReader, subscriptionID string, expectedLines []expectedLine) { |
| s.T().Helper() |
|
|
| lines := invoice.GetGenericLines() |
| if lines.IsAbsent() { |
| s.Failf("lines not found", "lines not found for invoice %s", invoice.GetID()) |
| } |
|
|
| existingLineChildIDs := lo.Map(lines.OrEmpty(), func(line billing.GenericInvoiceLine, _ int) string { |
| return lo.FromPtrOr(line.GetChildUniqueReferenceID(), line.GetID()) |
| }) |
|
|
| expectedLineIDs := lo.Flatten(lo.Map(expectedLines, func(expectedLine expectedLine, _ int) []string { |
| return expectedLine.Matcher.ChildIDs(subscriptionID) |
| })) |
|
|
| s.ElementsMatch(expectedLineIDs, existingLineChildIDs) |
|
|
| for _, expectedLine := range expectedLines { |
| childIDs := expectedLine.Matcher.ChildIDs(subscriptionID) |
| for idx, childID := range childIDs { |
| line, found := lo.Find(lines.OrEmpty(), func(line billing.GenericInvoiceLine) bool { |
| return lo.FromPtrOr(line.GetChildUniqueReferenceID(), line.GetID()) == childID |
| }) |
| s.Truef(found, "line not found with child id %s", childID) |
| s.NotNil(line) |
|
|
| if expectedLine.Qty.IsPresent() { |
| lineQuantityAccessor, ok := line.(billing.QuantityAccessor) |
| if !ok { |
| s.Failf("line is not a quantity accessor", "line is not a quantity accessor with child id %s", childID) |
| } |
|
|
| lineQuantity := lineQuantityAccessor.GetQuantity() |
| if lineQuantity == nil { |
| s.Failf("line quantity not found", "line quantity not found with child id %s", childID) |
| } else { |
| s.Equal(expectedLine.Qty.OrEmpty(), lineQuantity.InexactFloat64(), "%s: quantity", childID) |
| } |
| } |
|
|
| if expectedLine.Price.IsPresent() { |
| expectedPrice := expectedLine.Price.OrEmpty() |
| actualPrice := line.GetPrice() |
| s.Truef(expectedPrice.Equal(actualPrice), "%s: price expected %v, got %v", childID, expectedPrice, actualPrice) |
| } |
|
|
| s.Equal(expectedLine.Periods[idx].From, line.GetServicePeriod().From, "%s: period start", childID) |
| s.Equal(expectedLine.Periods[idx].To, line.GetServicePeriod().To, "%s: period end", childID) |
|
|
| if expectedLine.InvoiceAt.IsPresent() { |
| invoiceAtAccessor, ok := line.(billing.InvoiceAtAccessor) |
| if !ok { |
| s.Failf("line is not a invoice at accessor", "line is not a invoice at accessor with child id %s", childID) |
| } |
|
|
| invoiceAt := invoiceAtAccessor.GetInvoiceAt() |
| s.Equal(expectedLine.InvoiceAt.OrEmpty()[idx], invoiceAt, "%s: invoice at", childID) |
| } |
|
|
| if expectedLine.AdditionalChecks != nil { |
| expectedLine.AdditionalChecks(line) |
| } |
| } |
| } |
| } |
|
|
| type expectedCharge struct { |
| Matcher lineMatcher |
| Type chargesmeta.ChargeType |
| Status string |
| Price *productcatalog.Price |
| Periods []timeutil.ClosedPeriod |
| FullServicePeriods []timeutil.ClosedPeriod |
| BillingPeriods []timeutil.ClosedPeriod |
| InvoiceAt []*time.Time |
| GatheringLines []expectedChargeGatheringLine |
| Realizations []expectedChargeRealization |
| } |
|
|
| type expectedChargeGatheringLine struct { |
| LineMatcher lineMatcher |
| Period timeutil.ClosedPeriod |
| Price *productcatalog.Price |
| InvoiceAt *time.Time |
| } |
|
|
| type expectedChargeRealization struct { |
| LineMatcher lineMatcher |
| Period timeutil.ClosedPeriod |
| Status billing.StandardInvoiceStatus |
| IsVoided bool |
| BookedAt time.Time |
| Price *productcatalog.Price |
| Totals totals.Totals |
| } |
|
|
| type actualChargeGatheringLine struct { |
| Period timeutil.ClosedPeriod |
| Price *productcatalog.Price |
| InvoiceAt time.Time |
| } |
|
|
| type chargeRealizationKey struct { |
| Period timeutil.ClosedPeriod |
| Status billing.StandardInvoiceStatus |
| IsVoided bool |
| } |
|
|
| type actualChargeLedgerTransactionGroup struct { |
| ID string |
| Label string |
| } |
|
|
| func (s *SuiteBase) assertCharges(ctx context.Context, subsView subscription.SubscriptionView, expectedCharges []expectedCharge) { |
| s.T().Helper() |
|
|
| subscriptionID := subsView.Subscription.NamespacedID |
|
|
| res, err := s.Charges.ListCharges(ctx, charges.ListChargesInput{ |
| Namespace: subscriptionID.Namespace, |
| SubscriptionIDs: []string{subscriptionID.ID}, |
| IncludeDeleted: true, |
| Expands: chargesmeta.Expands{ |
| chargesmeta.ExpandRealizations, |
| chargesmeta.ExpandDeletedRealizations, |
| }, |
| }) |
| s.NoError(err) |
|
|
| expectedChargeIDs := lo.Flatten(lo.Map(expectedCharges, func(expectedCharge expectedCharge, _ int) []string { |
| s.Require().NotNil(expectedCharge.Matcher, "expected charge matcher") |
|
|
| return expectedCharge.Matcher.ChildIDs(subscriptionID.ID) |
| })) |
| actualChargeIDs := lo.Map(res.Items, func(charge charges.Charge, _ int) string { |
| uniqueReferenceID, err := charge.GetUniqueReferenceID() |
| s.NoError(err) |
| s.Require().NotNil(uniqueReferenceID, "charge %s child unique reference id", charge.GetID()) |
|
|
| return *uniqueReferenceID |
| }) |
|
|
| s.Len(res.Items, len(expectedChargeIDs)) |
| s.ElementsMatch(expectedChargeIDs, actualChargeIDs) |
|
|
| for _, expectedCharge := range expectedCharges { |
| s.Require().NotNil(expectedCharge.Matcher, "expected charge matcher") |
|
|
| childIDs := expectedCharge.Matcher.ChildIDs(subscriptionID.ID) |
| s.Require().NotEmpty(expectedCharge.Type, "expected charge type") |
| s.Require().NotEmpty(expectedCharge.Status, "expected charge status") |
| s.Require().Len(expectedCharge.Periods, len(childIDs), "expected charge periods") |
| if len(expectedCharge.InvoiceAt) > 0 { |
| s.Require().Len(expectedCharge.InvoiceAt, len(childIDs), "expected charge invoice at") |
| } |
| if len(expectedCharge.FullServicePeriods) > 0 { |
| s.Require().Len(expectedCharge.FullServicePeriods, len(childIDs), "expected charge full service periods") |
| } |
| if len(expectedCharge.BillingPeriods) > 0 { |
| s.Require().Len(expectedCharge.BillingPeriods, len(childIDs), "expected charge billing periods") |
| } |
|
|
| for idx, childID := range childIDs { |
| charge, found := lo.Find(res.Items, func(charge charges.Charge) bool { |
| uniqueReferenceID, err := charge.GetUniqueReferenceID() |
| s.NoError(err) |
|
|
| return uniqueReferenceID != nil && *uniqueReferenceID == childID |
| }) |
| s.Require().Truef(found, "charge not found with child unique reference id %s", childID) |
|
|
| s.assertCharge(ctx, charge, subsView, childID, childIDs, expectedCharge, idx) |
| } |
| } |
| } |
|
|
| func (s *SuiteBase) assertCharge(ctx context.Context, charge charges.Charge, subsView subscription.SubscriptionView, childID string, expectedChargeIDs []string, expectedCharge expectedCharge, idx int) { |
| s.T().Helper() |
|
|
| chargeID, err := charge.GetChargeID() |
| s.NoError(err) |
|
|
| s.Equal(expectedCharge.Type, charge.Type(), "%s: type", childID) |
|
|
| phaseKey, itemKey, _ := s.chargeExpectedLineMatcherParts(expectedCharge.Matcher) |
| phase := s.getPhaseByKey(s.T(), subsView, phaseKey) |
|
|
| switch charge.Type() { |
| case chargesmeta.ChargeTypeUsageBased: |
| usageBasedCharge, err := charge.AsUsageBasedCharge() |
| s.NoError(err) |
| baseIntent := usageBasedCharge.Intent.GetBaseIntent() |
| subscription := usageBasedCharge.Intent.GetSubscription() |
|
|
| s.Equal(expectedCharge.Status, string(usageBasedCharge.Status), "%s: status", childID) |
| s.Equal(subsView.Subscription.SettlementMode, usageBasedCharge.Intent.GetSettlementMode(), "%s: settlement mode", childID) |
| s.Equal(s.Customer.ID, usageBasedCharge.Intent.GetCustomerID(), "%s: customer id", childID) |
| s.Equal(subsView.Subscription.Currency, usageBasedCharge.Intent.GetCurrency(), "%s: currency", childID) |
| s.Equal(expectedCharge.Periods[idx], baseIntent.ServicePeriod, "%s: service period", childID) |
| if len(expectedCharge.FullServicePeriods) > 0 { |
| s.Equal(expectedCharge.FullServicePeriods[idx], baseIntent.FullServicePeriod, "%s: full service period", childID) |
| } |
| if len(expectedCharge.BillingPeriods) > 0 { |
| s.Equal(expectedCharge.BillingPeriods[idx], baseIntent.BillingPeriod, "%s: billing period", childID) |
| } |
| if expectedCharge.Price != nil { |
| s.Truef(expectedCharge.Price.Equal(&baseIntent.Price), "%s: price expected %v, got %v", childID, expectedCharge.Price, baseIntent.Price) |
| } |
| if len(expectedCharge.InvoiceAt) > idx && expectedCharge.InvoiceAt[idx] != nil { |
| s.Equal(*expectedCharge.InvoiceAt[idx], baseIntent.InvoiceAt, "%s: invoice at", childID) |
| } |
| s.Require().NotNil(subscription, "%s: subscription", childID) |
| s.Equal(subsView.Subscription.ID, subscription.SubscriptionID, "%s: subscription id", childID) |
| s.Equal(phase.SubscriptionPhase.ID, subscription.PhaseID, "%s: phase id", childID) |
| |
| |
| item, itemFound := s.subscriptionItemByID(phase, itemKey, subscription.ItemID, childID, usageBasedCharge.DeletedAt != nil) |
| expectedFeatureKey := itemKey |
| if itemFound { |
| expectedFeatureKey = lo.FromPtrOr(item.Spec.RateCard.AsMeta().FeatureKey, itemKey) |
| } |
| s.Equal(expectedFeatureKey, baseIntent.FeatureKey, "%s: feature key", childID) |
| case chargesmeta.ChargeTypeFlatFee: |
| flatFeeCharge, err := charge.AsFlatFeeCharge() |
| s.NoError(err) |
| baseIntent := flatFeeCharge.Intent.GetBaseIntent() |
| subscription := flatFeeCharge.Intent.GetSubscription() |
|
|
| s.Equal(expectedCharge.Status, string(flatFeeCharge.Status), "%s: status", childID) |
| s.Equal(subsView.Subscription.SettlementMode, flatFeeCharge.Intent.GetSettlementMode(), "%s: settlement mode", childID) |
| s.Equal(s.Customer.ID, flatFeeCharge.Intent.GetCustomerID(), "%s: customer id", childID) |
| s.Equal(subsView.Subscription.Currency, flatFeeCharge.Intent.GetCurrency(), "%s: currency", childID) |
| s.Equal(expectedCharge.Periods[idx], baseIntent.ServicePeriod, "%s: service period", childID) |
| if len(expectedCharge.FullServicePeriods) > 0 { |
| s.Equal(expectedCharge.FullServicePeriods[idx], baseIntent.FullServicePeriod, "%s: full service period", childID) |
| } |
| if len(expectedCharge.BillingPeriods) > 0 { |
| s.Equal(expectedCharge.BillingPeriods[idx], baseIntent.BillingPeriod, "%s: billing period", childID) |
| } |
| if expectedCharge.Price != nil { |
| expectedFlatPrice, err := expectedCharge.Price.AsFlat() |
| s.NoError(err) |
| require.Equal(s.T(), expectedFlatPrice.Amount.InexactFloat64(), baseIntent.AmountBeforeProration.InexactFloat64(), fmt.Sprintf("%s: amount before proration", childID)) |
| } |
| if len(expectedCharge.InvoiceAt) > idx && expectedCharge.InvoiceAt[idx] != nil { |
| s.Equal(*expectedCharge.InvoiceAt[idx], baseIntent.InvoiceAt, "%s: invoice at", childID) |
| } |
| s.Require().NotNil(subscription, "%s: subscription", childID) |
| s.Equal(subsView.Subscription.ID, subscription.SubscriptionID, "%s: subscription id", childID) |
| s.Equal(phase.SubscriptionPhase.ID, subscription.PhaseID, "%s: phase id", childID) |
| |
| |
| if item, itemFound := s.subscriptionItemByID(phase, itemKey, subscription.ItemID, childID, flatFeeCharge.DeletedAt != nil); itemFound { |
| s.Equal(item.SubscriptionItem.ID, subscription.ItemID, "%s: item id", childID) |
| } |
| default: |
| s.Failf("unsupported charge type", "charge %s has unsupported type %s", chargeID.ID, charge.Type()) |
| } |
|
|
| s.assertChargePaymentLedgerTransactions(ctx, charge, childID) |
| s.assertChargeGatheringLines(ctx, charge, subsView.Subscription.ID, childID, expectedChargeIDs, expectedCharge.Periods[idx], expectedCharge.Price, expectedCharge.GatheringLines) |
|
|
| expectedRealizations := s.expectedRealizationsForCharge(subsView.Subscription.ID, childID, expectedChargeIDs, expectedCharge.Periods[idx], expectedCharge.Realizations) |
| actualRealizations := s.chargeRealizations(ctx, charge) |
| expectedRealizationKeys := lo.Map(expectedRealizations, func(realization expectedChargeRealization, _ int) chargeRealizationKey { |
| return chargeRealizationKey{ |
| Period: realization.Period, |
| Status: realization.Status, |
| IsVoided: realization.IsVoided, |
| } |
| }) |
| actualRealizationKeys := lo.Map(actualRealizations, func(realization actualChargeRealization, _ int) chargeRealizationKey { |
| return chargeRealizationKey{ |
| Period: realization.Period, |
| Status: realization.Status, |
| IsVoided: realization.IsVoided, |
| } |
| }) |
|
|
| s.ElementsMatch(expectedRealizationKeys, actualRealizationKeys, "%s: realizations", childID) |
|
|
| remainingActualRealizations := slices.Clone(actualRealizations) |
| for _, expectedRealization := range expectedRealizations { |
| actualRealization, idx, found := lo.FindIndexOf(remainingActualRealizations, func(realization actualChargeRealization) bool { |
| return realization.Period == expectedRealization.Period && |
| realization.Status == expectedRealization.Status && |
| realization.IsVoided == expectedRealization.IsVoided |
| }) |
| if !found { |
| s.Failf("realization not found", "realization not found for charge %s with status %s and period %s", childID, expectedRealization.Status, expectedRealization.Period) |
| continue |
| } |
| remainingActualRealizations = slices.Delete(remainingActualRealizations, idx, idx+1) |
|
|
| expectedPrice := expectedRealization.Price |
| if expectedPrice == nil { |
| expectedPrice = expectedCharge.Price |
| } |
| if expectedPrice != nil { |
| s.Truef(expectedPrice.Equal(actualRealization.Price), "%s: realization price expected %v, got %v", childID, expectedPrice, actualRealization.Price) |
| } |
|
|
| if !expectedRealization.Totals.IsZero() { |
| s.Truef(expectedRealization.Totals.Equal(actualRealization.Totals), "%s: realization totals expected %v, got %v", childID, expectedRealization.Totals, actualRealization.Totals) |
| } |
|
|
| s.assertChargeRealizationLedgerTransactions(ctx, chargeID.Namespace, childID, expectedRealization, actualRealization) |
| } |
| } |
|
|
| func (s *SuiteBase) assertChargeRealizationLedgerTransactions(ctx context.Context, namespace string, childID string, expectedRealization expectedChargeRealization, actualRealization actualChargeRealization) { |
| s.T().Helper() |
|
|
| s.Require().False(expectedRealization.BookedAt.IsZero(), "%s: realization booked_at", childID) |
| if len(actualRealization.LedgerTransactionGroups) == 0 { |
| s.Require().False(expectedChargeRealizationRequiresLedgerTransactionGroups(expectedRealization), "%s: realization ledger transaction groups", childID) |
| return |
| } |
|
|
| for _, transactionGroup := range actualRealization.LedgerTransactionGroups { |
| s.assertLedgerTransactionGroupBookedAt(ctx, namespace, transactionGroup.ID, expectedRealization.BookedAt, fmt.Sprintf("%s: %s", childID, transactionGroup.Label)) |
| } |
| } |
|
|
| func expectedChargeRealizationRequiresLedgerTransactionGroups(expectedRealization expectedChargeRealization) bool { |
| if expectedRealization.Totals.IsZero() { |
| return false |
| } |
|
|
| switch expectedRealization.Status.ShortStatus() { |
| case "draft", "gathering", "delete": |
| return false |
| default: |
| return true |
| } |
| } |
|
|
| func (s *SuiteBase) assertChargePaymentLedgerTransactions(ctx context.Context, charge charges.Charge, childID string) { |
| s.T().Helper() |
|
|
| chargeID, err := charge.GetChargeID() |
| s.NoError(err) |
|
|
| switch charge.Type() { |
| case chargesmeta.ChargeTypeFlatFee: |
| flatFeeCharge, err := charge.AsFlatFeeCharge() |
| s.NoError(err) |
|
|
| runs := slices.Clone(flatFeeCharge.Realizations.PriorRuns) |
| if flatFeeCharge.Realizations.CurrentRun != nil { |
| runs = append(runs, *flatFeeCharge.Realizations.CurrentRun) |
| } |
|
|
| for _, run := range runs { |
| s.assertPaymentLedgerTransactions(ctx, chargeID.Namespace, childID, run.Payment) |
| } |
| case chargesmeta.ChargeTypeUsageBased: |
| usageBasedCharge, err := charge.AsUsageBasedCharge() |
| s.NoError(err) |
|
|
| for _, run := range usageBasedCharge.Realizations { |
| s.assertPaymentLedgerTransactions(ctx, chargeID.Namespace, childID, run.Payment) |
| } |
| } |
| } |
|
|
| func (s *SuiteBase) assertPaymentLedgerTransactions(ctx context.Context, namespace string, childID string, payment *chargepayment.Invoiced) { |
| s.T().Helper() |
|
|
| if payment == nil { |
| return |
| } |
|
|
| if payment.Authorized != nil && payment.Authorized.TransactionGroupID != "" { |
| s.assertLedgerTransactionGroupBookedAt(ctx, namespace, payment.Authorized.TransactionGroupID, payment.Authorized.Time, fmt.Sprintf("%s: payment authorization", childID)) |
| } |
| if payment.Settled != nil && payment.Settled.TransactionGroupID != "" { |
| s.assertLedgerTransactionGroupBookedAt(ctx, namespace, payment.Settled.TransactionGroupID, payment.Settled.Time, fmt.Sprintf("%s: payment settlement", childID)) |
| } |
| } |
|
|
| func (s *SuiteBase) assertLedgerTransactionGroupBookedAt(ctx context.Context, namespace string, groupID string, expectedBookedAt time.Time, label string) { |
| s.T().Helper() |
|
|
| s.Require().NotEmpty(groupID, "%s: ledger transaction group id", label) |
| s.Require().False(expectedBookedAt.IsZero(), "%s: expected booked_at", label) |
| s.Require().NotNil(s.Ledger, "%s: ledger service", label) |
|
|
| group, err := s.Ledger.GetTransactionGroup(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: groupID, |
| }) |
| s.NoError(err, "%s: get ledger transaction group %s", label, groupID) |
|
|
| transactions := group.Transactions() |
| s.Require().NotEmpty(transactions, "%s: ledger transaction group %s transactions", label, groupID) |
|
|
| for _, transaction := range transactions { |
| s.Truef( |
| transaction.BookedAt().UTC().Equal(expectedBookedAt.UTC()), |
| "%s: transaction %s in group %s booked_at expected %s, got %s", |
| label, |
| transaction.ID().ID, |
| groupID, |
| expectedBookedAt.UTC(), |
| transaction.BookedAt().UTC(), |
| ) |
| } |
| } |
|
|
| func (s *SuiteBase) subscriptionItemByID(phase subscription.SubscriptionPhaseView, itemKey string, itemID string, childID string, allowMissing bool) (subscription.SubscriptionItemView, bool) { |
| s.T().Helper() |
|
|
| item, found := lo.Find(phase.ItemsByKey[itemKey], func(item subscription.SubscriptionItemView) bool { |
| return item.SubscriptionItem.ID == itemID |
| }) |
| if !allowMissing { |
| s.Require().Truef(found, "%s: subscription item id %s belongs to phase %s item %s", childID, itemID, phase.SubscriptionPhase.Key, itemKey) |
| } |
|
|
| return item, found |
| } |
|
|
| func (s *SuiteBase) assertChargeGatheringLines(ctx context.Context, charge charges.Charge, subscriptionID string, childID string, expectedChargeIDs []string, chargePeriod timeutil.ClosedPeriod, chargePrice *productcatalog.Price, expectedGatheringLines []expectedChargeGatheringLine) { |
| s.T().Helper() |
|
|
| expectedLines := s.expectedGatheringLinesForCharge(subscriptionID, childID, expectedChargeIDs, chargePeriod, expectedGatheringLines) |
| actualLines := s.gatheringChargeLines(ctx, charge) |
|
|
| expectedKeys := lo.Map(expectedLines, func(line expectedChargeGatheringLine, _ int) timeutil.ClosedPeriod { |
| return line.Period |
| }) |
| actualKeys := lo.Map(actualLines, func(line actualChargeGatheringLine, _ int) timeutil.ClosedPeriod { |
| return line.Period |
| }) |
|
|
| s.ElementsMatch(expectedKeys, actualKeys, "%s: gathering lines", childID) |
|
|
| remainingActualLines := slices.Clone(actualLines) |
| for _, expectedLine := range expectedLines { |
| actualLine, idx, found := lo.FindIndexOf(remainingActualLines, func(line actualChargeGatheringLine) bool { |
| return line.Period == expectedLine.Period |
| }) |
| if !found { |
| s.Failf("gathering line not found", "gathering line not found for charge %s with period %s", childID, expectedLine.Period) |
| continue |
| } |
| remainingActualLines = slices.Delete(remainingActualLines, idx, idx+1) |
|
|
| expectedPrice := expectedLine.Price |
| if expectedPrice == nil { |
| expectedPrice = chargePrice |
| } |
| if expectedPrice != nil { |
| s.Truef(expectedPrice.Equal(actualLine.Price), "%s: gathering line price expected %v, got %v", childID, expectedPrice, actualLine.Price) |
| } |
| if expectedLine.InvoiceAt != nil { |
| s.Equal(*expectedLine.InvoiceAt, actualLine.InvoiceAt, "%s: gathering line invoice at", childID) |
| } |
| } |
| } |
|
|
| func (s *SuiteBase) expectedGatheringLinesForCharge(subscriptionID string, childID string, expectedChargeIDs []string, chargePeriod timeutil.ClosedPeriod, expectedGatheringLines []expectedChargeGatheringLine) []expectedChargeGatheringLine { |
| s.T().Helper() |
|
|
| return lo.FilterMap(expectedGatheringLines, func(line expectedChargeGatheringLine, _ int) (expectedChargeGatheringLine, bool) { |
| if line.LineMatcher == nil { |
| s.Require().Len(expectedChargeIDs, 1, "%s: gathering line matcher is required when a charge expectation expands to multiple charges", childID) |
| if lo.IsEmpty(line.Period) { |
| line.Period = chargePeriod |
| } |
|
|
| return line, true |
| } |
|
|
| gatheringLineChildIDs := line.LineMatcher.ChildIDs(subscriptionID) |
| matchingChargeIDs := lo.Intersect(gatheringLineChildIDs, expectedChargeIDs) |
| s.Require().NotEmpty(matchingChargeIDs, "%s: gathering line matcher must belong to the charge expectation", childID) |
|
|
| if len(expectedChargeIDs) == 1 { |
| s.Require().Contains(gatheringLineChildIDs, childID, "%s: gathering line matcher must match the charge", childID) |
| } |
|
|
| if lo.IsEmpty(line.Period) { |
| line.Period = chargePeriod |
| } |
|
|
| return line, lo.Contains(gatheringLineChildIDs, childID) |
| }) |
| } |
|
|
| func (s *SuiteBase) expectedRealizationsForCharge(subscriptionID string, childID string, expectedChargeIDs []string, chargePeriod timeutil.ClosedPeriod, expectedRealizations []expectedChargeRealization) []expectedChargeRealization { |
| s.T().Helper() |
|
|
| return lo.FilterMap(expectedRealizations, func(realization expectedChargeRealization, _ int) (expectedChargeRealization, bool) { |
| if realization.LineMatcher == nil { |
| s.Require().Len(expectedChargeIDs, 1, "%s: realization matcher is required when a charge expectation expands to multiple charges", childID) |
| if lo.IsEmpty(realization.Period) { |
| realization.Period = chargePeriod |
| } |
|
|
| return realization, true |
| } |
|
|
| realizationChildIDs := realization.LineMatcher.ChildIDs(subscriptionID) |
| matchingChargeIDs := lo.Intersect(realizationChildIDs, expectedChargeIDs) |
| s.Require().NotEmpty(matchingChargeIDs, "%s: realization matcher must belong to the charge expectation", childID) |
|
|
| if len(expectedChargeIDs) == 1 { |
| s.Require().Contains(realizationChildIDs, childID, "%s: realization matcher must match the charge", childID) |
| } |
|
|
| if lo.IsEmpty(realization.Period) { |
| realization.Period = chargePeriod |
| } |
|
|
| return realization, lo.Contains(realizationChildIDs, childID) |
| }) |
| } |
|
|
| func (s *SuiteBase) chargeRealizations(ctx context.Context, charge charges.Charge) []actualChargeRealization { |
| s.T().Helper() |
|
|
| chargeID, err := charge.GetChargeID() |
| s.NoError(err) |
|
|
| var out []actualChargeRealization |
|
|
| switch charge.Type() { |
| case chargesmeta.ChargeTypeUsageBased: |
| usageBasedCharge, err := charge.AsUsageBasedCharge() |
| s.NoError(err) |
|
|
| for _, run := range usageBasedCharge.Realizations { |
| if run.DeletedAt != nil { |
| continue |
| } |
| if run.InvoiceID == nil || run.LineID == nil { |
| continue |
| } |
|
|
| realization := s.standardLineChargeRealization(ctx, billing.InvoiceID{ |
| Namespace: chargeID.Namespace, |
| ID: *run.InvoiceID, |
| }, *run.LineID) |
| realization.IsVoided = run.IsVoidedBillingHistory() |
| realization.LedgerTransactionGroups = s.usageBasedRunLedgerTransactionGroups(run) |
|
|
| out = append(out, realization) |
| } |
| case chargesmeta.ChargeTypeFlatFee: |
| flatFeeCharge, err := charge.AsFlatFeeCharge() |
| s.NoError(err) |
|
|
| runs := flatFeeCharge.Realizations.PriorRuns |
| if flatFeeCharge.Realizations.CurrentRun != nil { |
| runs = append(runs, *flatFeeCharge.Realizations.CurrentRun) |
| } |
|
|
| for _, run := range runs { |
| if run.DeletedAt != nil { |
| continue |
| } |
| if run.InvoiceID == nil || run.LineID == nil { |
| continue |
| } |
|
|
| realization := s.standardLineChargeRealization(ctx, billing.InvoiceID{ |
| Namespace: chargeID.Namespace, |
| ID: *run.InvoiceID, |
| }, *run.LineID) |
| realization.IsVoided = run.IsVoidedBillingHistory() |
| realization.LedgerTransactionGroups = s.flatFeeRunLedgerTransactionGroups(run) |
|
|
| out = append(out, realization) |
| } |
| } |
|
|
| return out |
| } |
|
|
| func (s *SuiteBase) usageBasedRunLedgerTransactionGroups(run usagebased.RealizationRun) []actualChargeLedgerTransactionGroup { |
| s.T().Helper() |
|
|
| out := make([]actualChargeLedgerTransactionGroup, 0, len(run.CreditsAllocated)+1) |
| for _, realization := range run.CreditsAllocated { |
| if realization.LedgerTransaction.TransactionGroupID == "" { |
| continue |
| } |
|
|
| out = append(out, actualChargeLedgerTransactionGroup{ |
| ID: realization.LedgerTransaction.TransactionGroupID, |
| Label: fmt.Sprintf("usage-based credit realization %s", realization.ID), |
| }) |
| } |
| if run.InvoiceUsage != nil && run.InvoiceUsage.LedgerTransaction != nil && run.InvoiceUsage.LedgerTransaction.TransactionGroupID != "" { |
| out = append(out, actualChargeLedgerTransactionGroup{ |
| ID: run.InvoiceUsage.LedgerTransaction.TransactionGroupID, |
| Label: fmt.Sprintf("usage-based invoice usage %s", run.InvoiceUsage.ID), |
| }) |
| } |
|
|
| return out |
| } |
|
|
| func (s *SuiteBase) flatFeeRunLedgerTransactionGroups(run flatfee.RealizationRun) []actualChargeLedgerTransactionGroup { |
| s.T().Helper() |
|
|
| out := make([]actualChargeLedgerTransactionGroup, 0, len(run.CreditRealizations)+1) |
| for _, realization := range run.CreditRealizations { |
| if realization.LedgerTransaction.TransactionGroupID == "" { |
| continue |
| } |
|
|
| out = append(out, actualChargeLedgerTransactionGroup{ |
| ID: realization.LedgerTransaction.TransactionGroupID, |
| Label: fmt.Sprintf("flat fee credit realization %s", realization.ID), |
| }) |
| } |
| if run.AccruedUsage != nil && run.AccruedUsage.LedgerTransaction != nil && run.AccruedUsage.LedgerTransaction.TransactionGroupID != "" { |
| out = append(out, actualChargeLedgerTransactionGroup{ |
| ID: run.AccruedUsage.LedgerTransaction.TransactionGroupID, |
| Label: fmt.Sprintf("flat fee accrued usage %s", run.AccruedUsage.ID), |
| }) |
| } |
|
|
| return out |
| } |
|
|
| type actualChargeRealization struct { |
| Period timeutil.ClosedPeriod |
| Status billing.StandardInvoiceStatus |
| IsVoided bool |
| Price *productcatalog.Price |
| Totals totals.Totals |
| LedgerTransactionGroups []actualChargeLedgerTransactionGroup |
| } |
|
|
| func (s *SuiteBase) standardLineChargeRealization(ctx context.Context, invoiceID billing.InvoiceID, lineID string) actualChargeRealization { |
| s.T().Helper() |
|
|
| invoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: invoiceID, |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| line := invoice.Lines.GetByID(lineID) |
| s.Require().NotNil(line, "standard invoice line %s", lineID) |
|
|
| return actualChargeRealization{ |
| Period: line.Period, |
| Status: invoice.Status, |
| Price: line.GetPrice(), |
| Totals: line.Totals, |
| } |
| } |
|
|
| func (s *SuiteBase) gatheringChargeLines(ctx context.Context, charge charges.Charge) []actualChargeGatheringLine { |
| s.T().Helper() |
|
|
| chargeID, err := charge.GetChargeID() |
| s.NoError(err) |
|
|
| customerID, err := charge.GetCustomerID() |
| s.NoError(err) |
|
|
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Namespaces: []string{chargeID.Namespace}, |
| Customers: []string{customerID.ID}, |
| Page: pagination.Page{ |
| PageSize: 100, |
| PageNumber: 1, |
| }, |
| Expand: billing.GatheringInvoiceExpands{ |
| billing.GatheringInvoiceExpandLines, |
| }, |
| }) |
| s.NoError(err) |
|
|
| var out []actualChargeGatheringLine |
| for _, invoice := range invoices.Items { |
| for _, line := range invoice.Lines.OrEmpty() { |
| if line.ChargeID == nil || *line.ChargeID != chargeID.ID { |
| continue |
| } |
|
|
| out = append(out, actualChargeGatheringLine{ |
| Period: line.ServicePeriod, |
| Price: line.GetPrice(), |
| InvoiceAt: line.GetInvoiceAt(), |
| }) |
| } |
| } |
|
|
| return out |
| } |
|
|
| type lineMatcher interface { |
| ChildIDs(subsID string) []string |
| } |
|
|
| func (s *SuiteBase) chargeExpectedLineMatcherParts(matcher lineMatcher) (string, string, int) { |
| s.T().Helper() |
|
|
| switch matcher := matcher.(type) { |
| case recurringLineMatcher: |
| return matcher.PhaseKey, matcher.ItemKey, matcher.Version |
| case oneTimeLineMatcher: |
| return matcher.PhaseKey, matcher.ItemKey, matcher.Version |
| default: |
| s.T().Fatalf("charge assertion does not support matcher type %T", matcher) |
| return "", "", 0 |
| } |
| } |
|
|
| type recurringLineMatcher struct { |
| PhaseKey string |
| ItemKey string |
| Version int |
| PeriodMin int |
| PeriodMax int |
| } |
|
|
| func (m recurringLineMatcher) ChildIDs(subsID string) []string { |
| out := []string{} |
| for periodID := m.PeriodMin; periodID <= m.PeriodMax; periodID++ { |
| out = append(out, fmt.Sprintf("%s/%s/%s/v[%d]/period[%d]", subsID, m.PhaseKey, m.ItemKey, m.Version, periodID)) |
| } |
|
|
| return out |
| } |
|
|
| type oneTimeLineMatcher struct { |
| PhaseKey string |
| ItemKey string |
| Version int |
| } |
|
|
| func (m oneTimeLineMatcher) ChildIDs(subsID string) []string { |
| return []string{fmt.Sprintf("%s/%s/%s/v[%d]", subsID, m.PhaseKey, m.ItemKey, m.Version)} |
| } |
|
|
| func (s *SuiteBase) phaseMeta(key string, duration string) productcatalog.PhaseMeta { |
| out := productcatalog.PhaseMeta{ |
| Key: key, |
| Name: key, |
| } |
|
|
| if duration != "" { |
| out.Duration = lo.ToPtr(datetime.MustParseDuration(s.T(), duration)) |
| } |
|
|
| return out |
| } |
|
|
| func (s *SuiteBase) enableProgressiveBilling() { |
| s.updateProfile(func(profile *billing.Profile) { |
| profile.WorkflowConfig.Invoicing.ProgressiveBilling = true |
| }) |
| } |
|
|
| func (s *SuiteBase) updateProfile(modify func(profile *billing.Profile)) { |
| defaultProfile, err := s.BillingService.GetDefaultProfile(s.T().Context(), billing.GetDefaultProfileInput{ |
| Namespace: s.Namespace, |
| }) |
| s.NoError(err) |
|
|
| modify(defaultProfile) |
|
|
| defaultProfile.AppReferences = nil |
|
|
| _, err = s.BillingService.UpdateProfile(s.T().Context(), billing.UpdateProfileInput(defaultProfile.BaseProfile)) |
| s.NoError(err) |
| } |
|
|
| type subscriptionAddItem struct { |
| PhaseKey string |
| ItemKey string |
| Price *productcatalog.Price |
| BillingCadence *datetime.ISODuration |
| FeatureKey string |
| TaxConfig *productcatalog.TaxConfig |
| } |
|
|
| func (i subscriptionAddItem) AsPatch() subscription.Patch { |
| var rc productcatalog.RateCard |
|
|
| meta := productcatalog.RateCardMeta{ |
| Name: i.ItemKey, |
| Key: i.ItemKey, |
| Price: i.Price, |
| FeatureKey: lo.EmptyableToPtr(i.FeatureKey), |
| TaxConfig: i.TaxConfig, |
| } |
|
|
| switch { |
| case i.Price == nil: |
| rc = &productcatalog.FlatFeeRateCard{ |
| RateCardMeta: meta, |
| BillingCadence: i.BillingCadence, |
| } |
| case i.Price.Type() == productcatalog.FlatPriceType: |
| rc = &productcatalog.FlatFeeRateCard{ |
| RateCardMeta: meta, |
| BillingCadence: i.BillingCadence, |
| } |
| default: |
| rc = &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: meta, |
| BillingCadence: *i.BillingCadence, |
| } |
| } |
|
|
| return patch.PatchAddItem{ |
| PhaseKey: i.PhaseKey, |
| ItemKey: i.ItemKey, |
| CreateInput: subscription.SubscriptionItemSpec{ |
| CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ |
| CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ |
| PhaseKey: i.PhaseKey, |
| ItemKey: i.ItemKey, |
| RateCard: rc, |
| }, |
| }, |
| }, |
| } |
| } |
|
|
| func (s *SuiteBase) generatePeriods(startStr, endStr string, cadenceStr string, n int) []timeutil.ClosedPeriod { |
| start := testutils.GetRFC3339Time(s.T(), startStr) |
| end := testutils.GetRFC3339Time(s.T(), endStr) |
| cadence := datetime.MustParseDuration(s.T(), cadenceStr) |
|
|
| out := []timeutil.ClosedPeriod{} |
|
|
| for n != 0 { |
| out = append(out, timeutil.ClosedPeriod{ |
| From: start, |
| To: end, |
| }) |
|
|
| start, _ = cadence.AddTo(start) |
| end, _ = cadence.AddTo(end) |
|
|
| n-- |
| } |
| return out |
| } |
|
|
| |
| |
| |
| func (s *SuiteBase) populateChildIDsFromParents(invoice billing.GenericInvoice) { |
| genericLinesOption := invoice.GetGenericLines() |
| if genericLinesOption.IsAbsent() { |
| s.Failf("lines not found", "lines not found for invoice %s", invoice.GetID()) |
| } |
|
|
| genericLines := genericLinesOption.OrEmpty() |
|
|
| for idx, line := range genericLines { |
| if line.GetChildUniqueReferenceID() == nil && line.GetSplitLineGroupID() != nil { |
| invoiceLine := line.AsInvoiceLine() |
| switch invoiceLine.Type() { |
| case billing.InvoiceLineTypeStandard: |
| stdInvoiceLine, err := invoiceLine.AsStandardLine() |
| s.NoError(err) |
|
|
| line.SetChildUniqueReferenceID(stdInvoiceLine.SplitLineHierarchy.Group.UniqueReferenceID) |
| case billing.InvoiceLineTypeGathering: |
| splitLineGroupID := line.GetSplitLineGroupID() |
| if splitLineGroupID == nil { |
| s.Failf("split line group id not found", "split line group id not found for line %s", line.GetID()) |
| return |
| } |
|
|
| splitLineGroup, err := s.BillingAdapter.GetSplitLineGroup(s.T().Context(), billing.GetSplitLineGroupInput{ |
| Namespace: s.Namespace, |
| ID: *splitLineGroupID, |
| }) |
| s.NoError(err) |
|
|
| line.SetChildUniqueReferenceID(splitLineGroup.Group.UniqueReferenceID) |
| default: |
| s.Failf("unexpected line type", "unexpected line type %s for line %s", invoiceLine.Type(), line.GetID()) |
| } |
| } |
|
|
| genericLines[idx] = line |
| } |
|
|
| err := invoice.SetLines(genericLines) |
| s.NoError(err) |
| } |
|
|
| func (s *SuiteBase) createSubscriptionFromPlanPhases(phases []productcatalog.Phase) subscription.SubscriptionView { |
| 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: datetime.MustParseDuration(s.T(), "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Enabled: true, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: phases, |
| }, |
| } |
|
|
| return s.createSubscriptionFromPlan(planInput) |
| } |
|
|
| func (s *SuiteBase) createSubscriptionFromPlan(planInput plan.CreatePlanInput) subscription.SubscriptionView { |
| return s.createSubscriptionFromPlanAt(planInput, clock.Now()) |
| } |
|
|
| func (s *SuiteBase) createSubscriptionFromPlanAt(planInput plan.CreatePlanInput, startAt time.Time) subscription.SubscriptionView { |
| ctx := s.T().Context() |
|
|
| plan, err := s.PlanService.CreatePlan(ctx, planInput) |
| s.NoError(err) |
|
|
| subscriptionPlan, err := s.SubscriptionPlanAdapter.GetVersion(ctx, s.Namespace, productcatalogsubscription.PlanRefInput{ |
| Key: plan.Key, |
| Version: lo.ToPtr(1), |
| }) |
| s.NoError(err) |
|
|
| subsView, err := s.SubscriptionWorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{ |
| ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ |
| Timing: subscription.Timing{ |
| Custom: lo.ToPtr(startAt), |
| }, |
| Name: "subs-1", |
| }, |
| Namespace: s.Namespace, |
| CustomerID: s.Customer.ID, |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
| return subsView |
| } |
|
|