| package service |
|
|
| import ( |
| "context" |
| "fmt" |
| "testing" |
| "time" |
|
|
| "github.com/alpacahq/alpacadecimal" |
| "github.com/invopop/gobl/currency" |
| "github.com/samber/lo" |
| "github.com/samber/mo" |
| "github.com/stretchr/testify/require" |
| "github.com/stretchr/testify/suite" |
|
|
| "github.com/openmeterio/openmeter/openmeter/billing" |
| "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync" |
| "github.com/openmeterio/openmeter/openmeter/customer" |
| "github.com/openmeterio/openmeter/openmeter/productcatalog" |
| "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" |
| productcatalogsubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" |
| "github.com/openmeterio/openmeter/openmeter/streaming" |
| "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/pkg/clock" |
| "github.com/openmeterio/openmeter/pkg/datetime" |
| "github.com/openmeterio/openmeter/pkg/filter" |
| "github.com/openmeterio/openmeter/pkg/models" |
| "github.com/openmeterio/openmeter/pkg/pagination" |
| "github.com/openmeterio/openmeter/pkg/timeutil" |
| ) |
|
|
| type SubscriptionHandlerTestSuite struct { |
| SuiteBase |
| } |
|
|
| func TestSubscriptionHandlerScenarios(t *testing.T) { |
| suite.Run(t, new(SubscriptionHandlerTestSuite)) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestSubscriptionHappyPath() { |
| ctx := s.testContext() |
| namespace := s.Namespace |
| start := s.mustParseTime("2024-01-01T00:00:00.123456Z") |
| clock.SetTime(start) |
| defer clock.ResetTime() |
| defer s.MockStreamingConnector.Reset() |
|
|
| _ = s.InstallSandboxApp(s.T(), namespace) |
|
|
| s.enableProgressiveBilling() |
|
|
| plan, err := s.PlanService.CreatePlan(ctx, plan.CreatePlanInput{ |
| NamespacedModel: models.NamespacedModel{ |
| Namespace: 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: []productcatalog.Phase{ |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "free trial", |
| Key: "free-trial", |
| Duration: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }, |
| |
| RateCards: productcatalog.RateCards{ |
| &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), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "discounted phase", |
| Key: "discounted-phase", |
| Duration: lo.ToPtr(datetime.MustParseDuration(s.T(), "P2M")), |
| }, |
| |
| RateCards: productcatalog.RateCards{ |
| &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(5), |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "final phase", |
| Key: "final-phase", |
| Duration: nil, |
| }, |
| RateCards: productcatalog.RateCards{ |
| &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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(err) |
| s.NotNil(plan) |
|
|
| subscriptionPlan, err := s.SubscriptionPlanAdapter.GetVersion(ctx, 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(start), |
| }, |
| Name: "subs-1", |
| }, |
| Namespace: namespace, |
| CustomerID: s.Customer.ID, |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
|
|
| freeTierPhase := s.getPhaseByKey(s.T(), subsView, "free-trial") |
| s.Equal(lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), freeTierPhase.ItemsByKey[s.APIRequestsTotalFeature.Key][0].Spec.RateCard.GetBillingCadence()) |
|
|
| discountedPhase := s.getPhaseByKey(s.T(), subsView, "discounted-phase") |
| var gatheringInvoiceID billing.InvoiceID |
|
|
| |
| s.Run("provision first set of items", func() { |
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now().AddDate(0, 1, 0))) |
|
|
| invoices, err := s.BillingService.ListInvoices(ctx, billing.ListInvoicesInput{ |
| Namespaces: []string{namespace}, |
| CustomerID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &s.Customer.ID}}, |
| Page: pagination.Page{ |
| PageSize: 10, |
| PageNumber: 1, |
| }, |
| Expand: billing.InvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 1) |
|
|
| |
| invoice := s.gatheringInvoice(ctx, namespace, s.Customer.ID) |
| invoiceUpdatedAt := invoice.UpdatedAt |
|
|
| s.Len(invoice.Lines.OrEmpty(), 1) |
|
|
| line := invoice.Lines.OrEmpty()[0] |
| s.Equal(line.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(line.Subscription.PhaseID, discountedPhase.SubscriptionPhase.ID) |
| s.Equal(line.Subscription.ItemID, discountedPhase.ItemsByKey[s.APIRequestsTotalFeature.Key][0].SubscriptionItem.ID) |
| s.Equal(timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, line.Subscription.BillingPeriod) |
|
|
| |
| s.Equal(line.InvoiceAt, s.mustParseTime("2024-03-01T00:00:00Z")) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-02-01T00:00:00Z")) |
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now().AddDate(0, 1, 0))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, namespace, s.Customer.ID) |
| s.NoError(err) |
| gatheringInvoiceID = gatheringInvoice.GetInvoiceID() |
|
|
| s.DebugDumpInvoice("gathering invoice - 2nd update", gatheringInvoice) |
|
|
| gatheringLine := gatheringInvoice.Lines.OrEmpty()[0] |
|
|
| s.Equal(invoiceUpdatedAt, gatheringInvoice.GetUpdatedAt()) |
| s.Equal(line.GetUpdatedAt(), gatheringLine.GetUpdatedAt()) |
| }) |
|
|
| s.NoError(gatheringInvoiceID.Validate()) |
|
|
| |
| s.Run("progressive billing updates", func() { |
| s.MockStreamingConnector.AddSimpleEvent( |
| *s.APIRequestsTotalFeature.MeterSlug, |
| 100, |
| s.mustParseTime("2024-02-02T00:00:00Z")) |
| clock.FreezeTime(s.mustParseTime("2024-02-15T00:00:01Z")) |
|
|
| |
| invoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: customer.CustomerID{ |
| ID: s.Customer.ID, |
| Namespace: namespace, |
| }, |
| AsOf: lo.ToPtr(s.mustParseTime("2024-02-15T00:00:00Z")), |
| }) |
| if err != nil { |
| fmt.Printf("current time: %s\n", clock.Now().Format(time.RFC3339)) |
| } |
| s.NoError(err) |
| s.Len(invoices, 1) |
| invoice := invoices[0] |
|
|
| s.Equal(billing.StandardInvoiceStatusDraftWaitingAutoApproval, invoice.Status) |
| s.Equal(float64(5*100), invoice.Totals.Total.InexactFloat64()) |
|
|
| s.Len(invoice.Lines.OrEmpty(), 1) |
| line := invoice.Lines.OrEmpty()[0] |
| s.Equal(line.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(line.Subscription.PhaseID, discountedPhase.SubscriptionPhase.ID) |
| s.Equal(line.Subscription.ItemID, discountedPhase.ItemsByKey[s.APIRequestsTotalFeature.Key][0].SubscriptionItem.ID) |
| s.Equal(line.InvoiceAt, s.mustParseTime("2024-02-15T00:00:00Z")) |
| s.Equal(line.Period, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-15T00:00:00Z"), |
| }) |
|
|
| |
| gatheringInvoice, err := s.BillingService.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{ |
| Invoice: gatheringInvoiceID, |
| Expand: billing.GatheringInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.Len(gatheringInvoice.Lines.OrEmpty(), 1) |
| gatheringLine := gatheringInvoice.Lines.OrEmpty()[0] |
| s.Equal(gatheringLine.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(gatheringLine.Subscription.PhaseID, discountedPhase.SubscriptionPhase.ID) |
| s.Equal(gatheringLine.Subscription.ItemID, discountedPhase.ItemsByKey[s.APIRequestsTotalFeature.Key][0].SubscriptionItem.ID) |
| s.Equal(gatheringLine.InvoiceAt, s.mustParseTime("2024-03-01T00:00:00Z")) |
| s.Equal(gatheringLine.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-15T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }) |
|
|
| |
| |
| }) |
|
|
| s.Run("subscription cancellation", func() { |
| clock.FreezeTime(s.mustParseTime("2024-02-20T00:00:00Z")) |
|
|
| cancelAt := s.mustParseTime("2024-03-01T00:00:00.123456Z") |
| subs, err := s.SubscriptionService.Cancel(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: subsView.Subscription.ID, |
| }, subscription.Timing{ |
| Custom: lo.ToPtr(cancelAt), |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: subs.ID, |
| }) |
| s.NoError(err) |
|
|
| |
| |
|
|
| |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| gatheringInvoice, err := s.BillingService.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{ |
| Invoice: gatheringInvoiceID, |
| Expand: billing.GatheringInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.Len(gatheringInvoice.Lines.OrEmpty(), 1) |
| gatheringLine := gatheringInvoice.Lines.OrEmpty()[0] |
|
|
| s.Equal(gatheringLine.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(gatheringLine.Subscription.PhaseID, discountedPhase.SubscriptionPhase.ID) |
| s.Equal(gatheringLine.Subscription.ItemID, discountedPhase.ItemsByKey[s.APIRequestsTotalFeature.Key][0].SubscriptionItem.ID) |
|
|
| s.Equal(gatheringLine.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-15T00:00:00Z"), |
| To: cancelAt.Truncate(streaming.MinimumWindowSizeDuration), |
| }) |
| s.Equal(gatheringLine.InvoiceAt, cancelAt.Truncate(streaming.MinimumWindowSizeDuration)) |
|
|
| |
| s.NotNil(gatheringLine.SplitLineHierarchy) |
| splitLineGroup := gatheringLine.SplitLineHierarchy.Group |
|
|
| s.Equal(splitLineGroup.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(splitLineGroup.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }) |
| }) |
|
|
| s.Run("continue subscription", func() { |
| clock.FreezeTime(s.mustParseTime("2024-02-21T00:00:00Z")) |
|
|
| subs, err := s.SubscriptionService.Continue(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: subsView.Subscription.ID, |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: subs.ID, |
| }) |
| s.NoError(err) |
|
|
| |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| gatheringInvoice, err := s.BillingService.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{ |
| Invoice: gatheringInvoiceID, |
| Expand: billing.GatheringInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.Len(gatheringInvoice.Lines.OrEmpty(), 1) |
| gatheringLine := gatheringInvoice.Lines.OrEmpty()[0] |
|
|
| s.Equal(gatheringLine.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(gatheringLine.Subscription.PhaseID, discountedPhase.SubscriptionPhase.ID) |
| s.Equal(gatheringLine.Subscription.ItemID, discountedPhase.ItemsByKey[s.APIRequestsTotalFeature.Key][0].SubscriptionItem.ID) |
|
|
| s.Equal(gatheringLine.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-15T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }) |
| s.Equal(gatheringLine.InvoiceAt, s.mustParseTime("2024-03-01T00:00:00Z")) |
|
|
| |
| s.NotNil(gatheringLine.SplitLineHierarchy) |
| splitLineGroup := gatheringLine.SplitLineHierarchy.Group |
|
|
| s.Equal(splitLineGroup.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(splitLineGroup.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }) |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInArrearsProratingGathering() { |
| ctx := context.Background() |
| namespace := "test-subs-pro-rating" |
| start := s.mustParseTime("2024-01-01T00:00:00Z") |
| clock.SetTime(start) |
| defer clock.ResetTime() |
| s.enableProrating() |
|
|
| appSandbox := s.InstallSandboxApp(s.T(), namespace) |
|
|
| s.ProvisionBillingProfile(ctx, namespace, appSandbox.GetID()) |
|
|
| customerEntity := s.CreateTestCustomer(namespace, "test") |
| require.NotNil(s.T(), customerEntity) |
| require.NotEmpty(s.T(), customerEntity.ID) |
|
|
| plan, err := s.PlanService.CreatePlan(ctx, plan.CreatePlanInput{ |
| NamespacedModel: models.NamespacedModel{ |
| Namespace: 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: []productcatalog.Phase{ |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "first-phase", |
| Key: "first-phase", |
| Duration: nil, |
| }, |
| 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: datetime.MustParseDuration(s.T(), "P1D"), |
| }, |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "in-arrears-3m", |
| Name: "in-arrears-3m", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(9), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P3M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(err) |
| s.NotNil(plan) |
|
|
| subscriptionPlan, err := s.SubscriptionPlanAdapter.GetVersion(ctx, 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(start), |
| }, |
| Name: "subs-1", |
| }, |
| Namespace: namespace, |
| CustomerID: customerEntity.ID, |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
|
|
| |
| s.Run("provision first set of items", func() { |
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| |
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Namespaces: []string{namespace}, |
| Customers: []string{customerEntity.ID}, |
| Expand: billing.GatheringInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 1) |
|
|
| lines := invoices.Items[0].Lines.OrEmpty() |
| oneDayLines := lo.Filter(lines, func(line billing.GatheringLine, _ int) bool { |
| return line.ServicePeriod.Duration() == time.Hour*24 |
| }) |
| s.Len(oneDayLines, 31) |
|
|
| for _, line := range oneDayLines { |
| s.Equal(line.Subscription.SubscriptionID, subsView.Subscription.ID, "failed for line %v", line.ID) |
| s.Equal(line.Subscription.PhaseID, subsView.Phases[0].SubscriptionPhase.ID, "failed for line %v", line.ID) |
| s.Equal(line.Subscription.ItemID, subsView.Phases[0].ItemsByKey["in-arrears"][0].SubscriptionItem.ID, "failed for line %v", line.ID) |
| s.Equal(line.InvoiceAt, s.mustParseTime("2024-02-01T00:00:00Z"), "failed for line %v", line.ID) |
| s.Equal(line.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z").AddDate(0, 0, line.ServicePeriod.From.Day()-1), |
| To: s.mustParseTime("2024-01-01T00:00:00Z").AddDate(0, 0, line.ServicePeriod.From.Day()), |
| }, "failed for line %v", line.ID) |
| price, err := line.Price.AsFlat() |
| s.NoError(err) |
| s.Equal(price.Amount.InexactFloat64(), 5.0, "failed for line %v", line.ID) |
| s.Equal(price.PaymentTerm, productcatalog.InArrearsPaymentTerm, "failed for line %v", line.ID) |
| } |
| }) |
|
|
| s.Run("canceling the subscription inside the service period causes the existing item to be pro-rated", func() { |
| |
| clock.SetTime(s.mustParseTime("2024-01-01T10:00:00Z")) |
|
|
| cancelAt := s.mustParseTime("2024-02-01T00:00:00Z") |
| subs, err := s.SubscriptionService.Cancel(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: subsView.Subscription.ID, |
| }, subscription.Timing{ |
| Custom: lo.ToPtr(cancelAt), |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, models.NamespacedID{ |
| Namespace: namespace, |
| ID: subs.ID, |
| }) |
| s.NoError(err) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| |
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Namespaces: []string{namespace}, |
| Customers: []string{customerEntity.ID}, |
| Expand: billing.GatheringInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 1) |
|
|
| lines := invoices.Items[0].Lines.OrEmpty() |
| threeMonthLines := lo.Filter(lines, func(line billing.GatheringLine, _ int) bool { |
| return line.ServicePeriod.Duration() != time.Hour*24 |
| }) |
| s.Len(threeMonthLines, 1) |
|
|
| flatFeeLine := threeMonthLines[0] |
| s.Equal(flatFeeLine.Subscription.SubscriptionID, subsView.Subscription.ID) |
| s.Equal(flatFeeLine.InvoiceAt, cancelAt) |
| s.Equal(flatFeeLine.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: cancelAt, |
| }) |
| price, err := flatFeeLine.Price.AsFlat() |
| s.NoError(err) |
| s.Equal(price.Amount.InexactFloat64(), 3.07, "failed for line %v", flatFeeLine.ID) |
| s.Equal(price.PaymentTerm, productcatalog.InArrearsPaymentTerm, "failed for line %v", flatFeeLine.ID) |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncNonBillableAmountProrated() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.enableProrating() |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:40Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:40Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| s.mustParseTime("2024-02-01T00:00:00Z"), |
| }), |
| |
| |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceFlatFeeCancelAtFirstBillingBoundaryKeepsInitialProration() { |
| s.testInAdvanceFlatFeeCancelAtFirstBillingBoundaryProrationMode( |
| billing.SubscriptionEndProrationModeBillActualPeriod, |
| true, |
| productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6.51), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| ) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceFlatFeeCancelAtFirstBillingBoundaryBillActualPeriodMode() { |
| s.testInAdvanceFlatFeeCancelAtFirstBillingBoundaryProrationMode( |
| billing.SubscriptionEndProrationModeBillActualPeriod, |
| false, |
| productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6.51), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| ) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceFlatFeeCancelAtFirstBillingBoundaryBillFullPeriodMode() { |
| s.testInAdvanceFlatFeeCancelAtFirstBillingBoundaryProrationMode( |
| billing.SubscriptionEndProrationModeBillFullPeriod, |
| false, |
| productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(20), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| ) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) testInAdvanceFlatFeeCancelAtFirstBillingBoundaryProrationMode( |
| subscriptionEndProrationMode billing.SubscriptionEndProrationMode, |
| syncBeforeCancel bool, |
| expectedPriceAfterCancel *productcatalog.Price, |
| ) { |
| ctx := s.T().Context() |
| startAt := s.mustParseTime("2024-01-21T21:55:18Z") |
| billingAnchor := s.mustParseTime("2024-01-01T00:00:00Z") |
| firstBillingBoundary := s.mustParseTime("2024-02-01T00:00:00Z") |
| expectedProratedPrice := productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6.51), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }) |
| expectedFullPrice := productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(20), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }) |
|
|
| clock.FreezeTime(startAt) |
| defer clock.UnFreeze() |
| s.enableProrating() |
| s.updateProfile(func(profile *billing.Profile) { |
| profile.WorkflowConfig.Invoicing.SubscriptionEndProrationMode = subscriptionEndProrationMode |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| planEntity, err := s.PlanService.CreatePlan(ctx, 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: []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(20), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
| s.NoError(err) |
|
|
| subscriptionPlan, err := s.SubscriptionPlanAdapter.GetVersion(ctx, s.Namespace, productcatalogsubscription.PlanRefInput{ |
| Key: planEntity.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, |
| BillingAnchor: lo.ToPtr(billingAnchor), |
| }, subscriptionPlan) |
| s.NoError(err) |
|
|
| if syncBeforeCancel { |
| |
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, firstBillingBoundary)) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(expectedProratedPrice), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: startAt, |
| To: firstBillingBoundary, |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{startAt}), |
| AdditionalChecks: func(line billing.GenericInvoiceLine) { |
| flatPrice, err := line.GetPrice().AsFlat() |
| s.NoError(err) |
| s.False(flatPrice.Amount.Equal(alpacadecimal.NewFromFloat(20))) |
| }, |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(expectedFullPrice), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: firstBillingBoundary, |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{firstBillingBoundary}), |
| }, |
| }) |
| } |
|
|
| clock.FreezeTime(startAt.Add(13 * time.Second)) |
| subscriptionModel, err := s.SubscriptionService.Cancel(ctx, subsView.Subscription.NamespacedID, subscription.Timing{ |
| Custom: lo.ToPtr(firstBillingBoundary), |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, subscriptionModel.NamespacedID) |
| s.NoError(err) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, firstBillingBoundary)) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(expectedPriceAfterCancel), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: startAt, |
| To: firstBillingBoundary, |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{startAt}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncNonBillableAmount() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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: 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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| subsView := s.createSubscriptionFromPlan(planInput) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:40Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-01T00:00:40Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| }), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 1, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:40Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| s.mustParseTime("2024-02-01T00:00:00Z"), |
| }), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInArrearsGatheringSyncNonBillableAmount() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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: 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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| subsView := s.createSubscriptionFromPlan(planInput) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:40Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-01T00:00:40Z"), |
| }, |
| }, |
| |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:40Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncBillableAmountProrated() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.enableProrating() |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-02T00:00:00Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(20), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(0.32), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-02T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| }), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(19.35), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-02T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| }), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(20), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-02-01T00:00:00Z"), |
| }), |
| |
| |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncDraftInvoiceProrated() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.enableProrating() |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-02T00:00:00Z")) |
|
|
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| AsOf: lo.ToPtr(clock.Now()), |
| }) |
| s.NoError(err) |
| s.Require().Len(draftInvoices, 1) |
|
|
| s.DebugDumpInvoice("draft invoice", draftInvoices[0]) |
|
|
| draftInvoice := draftInvoices[0] |
| s.expectLines(draftInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(9.68), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-02T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| |
| draftInvoice, err = s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.expectLines(draftInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(0.19), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-02T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncIssuedInvoiceProrated() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.enableProrating() |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-02T00:00:00Z")) |
|
|
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| AsOf: lo.ToPtr(clock.Now()), |
| }) |
| s.NoError(err) |
| s.Require().Len(draftInvoices, 1) |
|
|
| draftInvoice := draftInvoices[0] |
| s.Equal(billing.StandardInvoiceStatusDraftWaitingAutoApproval, draftInvoice.Status) |
|
|
| approvedInvoice, err := s.BillingService.ApproveInvoice(ctx, draftInvoice.GetInvoiceID()) |
| s.NoError(err) |
| s.Equal(billing.StandardInvoiceStatusPaid, approvedInvoice.Status) |
|
|
| s.expectLines(approvedInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(9.68), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-02T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| |
| approvedInvoice, err = s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.expectLines(approvedInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
| s.Len(approvedInvoice.ValidationIssues, 1) |
|
|
| s.expectValidationIssueForLine(approvedInvoice.Lines.OrEmpty()[0], approvedInvoice.ValidationIssues[0]) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestDefactoZeroPrices() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| 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, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Enabled: true, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: []productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.FlatFeeRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "in-advance", |
| Name: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromInt(0), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1D")), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| |
|
|
| asOf := s.mustParseTime("2024-01-03T12:00:00Z") |
| s.NoError(s.Service.SyncByView(ctx, subView, asOf)) |
|
|
| invoices, err := s.BillingService.ListInvoices(ctx, billing.ListInvoicesInput{ |
| Namespaces: []string{s.Namespace}, |
| CustomerID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &s.Customer.ID}}, |
| Page: pagination.Page{ |
| PageSize: 10, |
| PageNumber: 1, |
| }, |
| Expand: billing.InvoiceExpandAll, |
| Statuses: []string{ |
| string(billing.StandardInvoiceStatusGathering), |
| }, |
| }) |
| require.NoError(s.T(), err) |
|
|
| |
| require.Len(s.T(), invoices.Items, 0) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionInvoicing() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| 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, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P4W"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Enabled: false, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: []productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-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(datetime.MustParseDuration(s.T(), "P1W")), |
| }, |
| &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(datetime.MustParseDuration(s.T(), "P1W")), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-01-02T00:00:00Z")) |
|
|
| subView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subView.Subscription.NamespacedID, []subscription.Patch{ |
| |
| &patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| &patch.PatchAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| CreateInput: subscription.SubscriptionItemSpec{ |
| CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ |
| CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| RateCard: &productcatalog.FlatFeeRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Name: "in-advance", |
| Key: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(8), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1W")), |
| }, |
| }, |
| }, |
| }, |
| }, |
| |
| &patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| }, |
| &patch.PatchAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| CreateInput: subscription.SubscriptionItemSpec{ |
| CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ |
| CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| RateCard: &productcatalog.FlatFeeRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Name: "in-arrears", |
| Key: "in-arrears", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(7), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| }), |
| }, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1W")), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, s.timingImmediate()) |
| s.NoError(err) |
|
|
| |
|
|
| asOf := s.mustParseTime("2024-01-03T12:00:00Z") |
| s.NoError(s.Service.SyncByView(ctx, subView, asOf)) |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-02T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 7, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(8), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-02T00:00:00Z"), |
| To: s.mustParseTime("2024-01-08T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-01-08T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-01-15T00:00:00Z"), |
| To: s.mustParseTime("2024-01-22T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-01-22T00:00:00Z"), |
| To: s.mustParseTime("2024-01-29T00:00:00Z"), |
| }, |
| |
| { |
| From: s.mustParseTime("2024-01-29T00:00:00Z"), |
| To: s.mustParseTime("2024-02-05T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-05T00:00:00Z"), |
| To: s.mustParseTime("2024-02-12T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-12T00:00:00Z"), |
| To: s.mustParseTime("2024-02-19T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-19T00:00:00Z"), |
| To: s.mustParseTime("2024-02-26T00:00:00Z"), |
| }, |
| }, |
| |
| InvoiceAt: mo.Some([]time.Time{ |
| |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| s.mustParseTime("2024-01-01T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| }), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-02T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-29T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| Version: 1, |
| PeriodMin: 0, |
| PeriodMax: 3, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(7), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-02T00:00:00Z"), |
| To: s.mustParseTime("2024-01-08T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-01-08T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-01-15T00:00:00Z"), |
| To: s.mustParseTime("2024-01-22T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-01-22T00:00:00Z"), |
| To: s.mustParseTime("2024-01-29T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| s.mustParseTime("2024-01-29T00:00:00Z"), |
| }), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionCancellation() { |
| ctx := s.T().Context() |
| startTime := s.mustParseTime("2024-01-01T00:00:00Z") |
| clock.FreezeTime(startTime) |
| defer clock.UnFreeze() |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| 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, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Enabled: true, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: []productcatalog.Phase{ |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "trial", |
| Key: "trial", |
| Duration: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }, |
| |
| RateCards: productcatalog.RateCards{ |
| &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), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "default", |
| Key: "default", |
| Duration: nil, |
| }, |
| |
| RateCards: productcatalog.RateCards{ |
| &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(5), |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| clock.FreezeTime(clock.Now().Add(time.Minute)) |
|
|
| |
| syncUntil := startTime.AddDate(0, 3, 0) |
| s.NoError(s.Service.SyncByView(ctx, subView, syncUntil)) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| |
| |
| s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "default", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromFloat(5)})), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: startTime.AddDate(0, 1, 0), |
| To: startTime.AddDate(0, 2, 0), |
| }, |
| { |
| From: startTime.AddDate(0, 2, 0), |
| To: startTime.AddDate(0, 3, 0), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| startTime.AddDate(0, 2, 0), |
| startTime.AddDate(0, 3, 0), |
| }), |
| }, |
| }) |
|
|
| |
| cancelAt := clock.Now().Add(time.Hour * 24) |
|
|
| clock.FreezeTime(cancelAt) |
| sub, err := s.SubscriptionService.Cancel(ctx, subView.Subscription.NamespacedID, subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingImmediate), |
| }) |
| s.NoError(err) |
|
|
| subView, err = s.SubscriptionService.GetView(ctx, sub.NamespacedID) |
| s.NoError(err) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subView, syncUntil)) |
|
|
| |
| s.expectNoGatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProgressiveBillingCancellation() { |
| ctx := s.T().Context() |
| startTime := s.mustParseTime("2024-01-01T00:00:00Z") |
| clock.FreezeTime(startTime) |
| defer clock.UnFreeze() |
|
|
| s.updateProfile(func(profile *billing.Profile) { |
| profile.WorkflowConfig.Invoicing = billing.InvoicingConfig{ |
| AutoAdvance: true, |
| DraftPeriod: datetime.MustParseDuration(s.T(), "P0D"), |
| ProgressiveBilling: true, |
| SubscriptionEndProrationMode: billing.SubscriptionEndProrationModeBillActualPeriod, |
| } |
|
|
| s.True(profile.Default) |
| }) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 1, s.mustParseTime("2023-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| testPrice := productcatalog.NewPriceFrom(productcatalog.TieredPrice{ |
| Mode: productcatalog.GraduatedTieredPrice, |
| Tiers: []productcatalog.PriceTier{ |
| { |
| UpToAmount: lo.ToPtr(alpacadecimal.NewFromFloat(1)), |
| FlatPrice: &productcatalog.PriceTierFlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }, |
| }, |
| { |
| UpToAmount: nil, |
| UnitPrice: &productcatalog.PriceTierUnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| 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, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Enabled: true, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: []productcatalog.Phase{ |
| { |
| PhaseMeta: productcatalog.PhaseMeta{ |
| Name: "default", |
| Key: "default", |
| Duration: nil, |
| }, |
| RateCards: productcatalog.RateCards{ |
| &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: testPrice, |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subView, clock.Now().Add(time.Minute))) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| |
| |
| s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "default", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Price: mo.Some(testPrice), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: startTime, |
| To: startTime.AddDate(0, 1, 0), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| startTime.AddDate(0, 1, 0), |
| }), |
| }, |
| }) |
|
|
| |
| |
| progressiveBilledAt := startTime.Add(time.Hour * 24) |
| clock.FreezeTime(progressiveBilledAt) |
|
|
| createdInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: customer.CustomerID{ |
| Namespace: s.Namespace, |
| ID: s.Customer.ID, |
| }, |
| AsOf: &progressiveBilledAt, |
| }) |
| s.NoError(err) |
| s.Len(createdInvoices, 1) |
| createdInvoice := createdInvoices[0] |
|
|
| |
| s.populateChildIDsFromParents(&createdInvoice) |
| s.DebugDumpInvoice("partial invoice", createdInvoice) |
|
|
| s.expectLines(createdInvoice, subView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "default", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Price: mo.Some(testPrice), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: startTime, |
| To: startTime.AddDate(0, 0, 1), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.populateChildIDsFromParents(&gatheringInvoice) |
| s.DebugDumpInvoice("gathering invoice - after progressive billing", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "default", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Price: mo.Some(testPrice), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: startTime.AddDate(0, 0, 1), |
| To: startTime.AddDate(0, 1, 0), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| startTime.AddDate(0, 1, 0), |
| }), |
| }, |
| }) |
|
|
| |
| |
| |
| cancelAt := progressiveBilledAt.Add(10 * time.Millisecond) |
|
|
| clock.FreezeTime(cancelAt) |
| sub, err := s.SubscriptionService.Cancel(ctx, subView.Subscription.NamespacedID, subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingImmediate), |
| }) |
| s.NoError(err) |
|
|
| subView, err = s.SubscriptionService.GetView(ctx, sub.NamespacedID) |
| s.NoError(err) |
|
|
| |
| clock.FreezeTime(clock.Now().Add(time.Second)) |
| |
| s.NoError(s.Service.SyncByView(ctx, subView, clock.Now())) |
|
|
| |
| s.expectNoGatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceOneTimeFeeSyncing() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-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, |
| }), |
| }, |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: oneTimeLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Version: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-01T00:00:00Z")}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInArrearsOneTimeFeeSyncing() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| 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: []productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.FlatFeeRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "in-arrears", |
| Name: "in-arrears", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| }), |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| 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(clock.Now()), |
| }, |
| Name: "subs-1", |
| }, |
| BillingAnchor: lo.ToPtr(s.mustParseTime("2023-12-15T00:00:00Z")), |
| Namespace: s.Namespace, |
| CustomerID: s.Customer.ID, |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.expectNoGatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
|
|
| |
| cancelAt := s.mustParseTime("2024-01-15T00:00:00Z") |
|
|
| subs, err := s.SubscriptionService.Cancel(ctx, subsView.Subscription.NamespacedID, subscription.Timing{ |
| Custom: &cancelAt, |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, subs.NamespacedID) |
| s.NoError(err) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: oneTimeLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| Version: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-15T00:00:00Z")}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestUsageBasedGatheringUpdate() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchAddPhase{ |
| PhaseKey: "second-phase", |
| CreateInput: subscription.CreateSubscriptionPhaseInput{ |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "second-phase", |
| Name: "second-phase", |
| StartAfter: datetime.MustParseDuration(s.T(), "P2D"), |
| }, |
| }, |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: s.APIRequestsTotalFeature.Key, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
|
|
| |
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-03T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-03T00:00:00Z")}), |
| }, |
| |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-03T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestUsageBasedGatheringUpdateDraftInvoice() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 0, s.mustParseTime("2023-01-01T00:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 2, s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 3, s.mustParseTime("2024-01-01T12:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 6, s.mustParseTime("2024-01-02T00:00:00Z")) |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-02-01T00:00:00Z")) |
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices, 1) |
|
|
| draftInvoice := draftInvoices[0] |
| s.DebugDumpInvoice("draft invoice", draftInvoice) |
| s.expectLines(draftInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Qty: mo.Some[float64](11), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-03-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| |
| |
| |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-30T00:00:00Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchAddPhase{ |
| PhaseKey: "second-phase", |
| CreateInput: subscription.CreateSubscriptionPhaseInput{ |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "second-phase", |
| Name: "second-phase", |
| StartAfter: datetime.MustParseDuration(s.T(), "P30D"), |
| }, |
| }, |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: s.APIRequestsTotalFeature.Key, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-02-01T00:00:00Z")) |
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-31T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-03-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| updatedDraftInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.DebugDumpInvoice("draft invoice - 2nd sync", updatedDraftInvoice) |
|
|
| s.expectLines(updatedDraftInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
|
|
| Qty: mo.Some[float64](11), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-31T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestUsageBasedGatheringUpdateIssuedInvoice() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 0, s.mustParseTime("2023-01-01T00:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 2, s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 3, s.mustParseTime("2024-01-01T12:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 6, s.mustParseTime("2024-01-02T00:00:00Z")) |
| |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 1, s.mustParseTime("2024-01-31T12:00:00Z")) |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-02-01T00:00:00Z")) |
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices, 1) |
|
|
| draftInvoice := draftInvoices[0] |
| s.Equal(billing.StandardInvoiceStatusDraftWaitingAutoApproval, draftInvoice.Status) |
|
|
| issuedInvoice, err := s.BillingService.ApproveInvoice(ctx, draftInvoice.GetInvoiceID()) |
| s.NoError(err) |
| s.Equal(billing.StandardInvoiceStatusPaid, issuedInvoice.Status) |
| s.Len(issuedInvoice.ValidationIssues, 0) |
| s.DebugDumpInvoice("issued invoice", issuedInvoice) |
| s.expectLines(issuedInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
|
|
| Qty: mo.Some[float64](12), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-01-30T00:00:00Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchAddPhase{ |
| PhaseKey: "second-phase", |
| CreateInput: subscription.CreateSubscriptionPhaseInput{ |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "second-phase", |
| Name: "second-phase", |
| StartAfter: datetime.MustParseDuration(s.T(), "P30D"), |
| }, |
| }, |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: s.APIRequestsTotalFeature.Key, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-02-01T00:00:00Z")) |
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| updatedIssuedInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: issuedInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.DebugDumpInvoice("issued invoice - 2nd sync", updatedIssuedInvoice) |
|
|
| s.expectLines(updatedIssuedInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
|
|
| Qty: mo.Some[float64](12), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.expectValidationIssueForLine(updatedIssuedInvoice.Lines.OrEmpty()[0], updatedIssuedInvoice.ValidationIssues[0]) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestUsageBasedUpdateWithLineSplits() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 0, s.mustParseTime("2023-01-01T00:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 1, s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 1, s.mustParseTime("2024-01-12T09:30:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 3, s.mustParseTime("2024-01-15T11:00:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 7, s.mustParseTime("2024-01-18T12:30:00Z")) |
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 11, s.mustParseTime("2024-01-29T00:00:00Z")) |
|
|
| s.enableProgressiveBilling() |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2024-01-15T00:00:00Z")) |
| draftInvoices1, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| AsOf: lo.ToPtr(s.mustParseTime("2024-01-15T00:00:00Z")), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices1, 1) |
|
|
| invoice1, err := s.BillingService.ApproveInvoice(ctx, draftInvoices1[0].GetInvoiceID()) |
| s.NoError(err) |
| s.Equal(billing.StandardInvoiceStatusPaid, invoice1.Status) |
|
|
| s.populateChildIDsFromParents(&invoice1) |
| s.DebugDumpInvoice("issued invoice1", invoice1) |
|
|
| s.expectLines(invoice1, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Qty: mo.Some[float64](2), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-18T00:00:00Z")) |
|
|
| |
| draftInvoices2, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| AsOf: lo.ToPtr(s.mustParseTime("2024-01-18T00:00:00Z")), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices2, 1) |
|
|
| draftInvoice2 := draftInvoices2[0] |
| s.populateChildIDsFromParents(&draftInvoice2) |
| s.DebugDumpInvoice("draft invoice2", draftInvoice2) |
| s.Equal(billing.StandardInvoiceStatusDraftWaitingAutoApproval, draftInvoice2.Status) |
|
|
| s.expectLines(draftInvoice2, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Qty: mo.Some[float64](3), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-15T00:00:00Z"), |
| To: s.mustParseTime("2024-01-18T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.populateChildIDsFromParents(&gatheringInvoice) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-18T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-02-01T00:00:00Z"), |
| s.mustParseTime("2024-03-01T00:00:00Z"), |
| }), |
| }, |
| }) |
| clock.FreezeTime(s.mustParseTime("2024-01-09T12:00:00Z")) |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchAddPhase{ |
| PhaseKey: "second-phase", |
| CreateInput: subscription.CreateSubscriptionPhaseInput{ |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "second-phase", |
| Name: "second-phase", |
| StartAfter: datetime.MustParseDuration(s.T(), "P10D"), |
| }, |
| }, |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: s.APIRequestsTotalFeature.Key, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
|
|
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| |
| |
| clock.FreezeTime(s.mustParseTime("2024-01-18T00:00:00Z")) |
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.populateChildIDsFromParents(&gatheringInvoice) |
| s.DebugDumpInvoice("gathering invoice - 2nd sync", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| Version: 0, |
| PeriodMin: 0, |
| PeriodMax: 1, |
| }, |
|
|
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-11T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2024-02-01T00:00:00Z"), |
| s.mustParseTime("2024-03-01T00:00:00Z"), |
| }), |
| }, |
| }) |
|
|
| |
| updatedIssuedInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: invoice1.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.populateChildIDsFromParents(&updatedIssuedInvoice) |
| s.DebugDumpInvoice("invoice1 (issued) - 2nd sync", updatedIssuedInvoice) |
|
|
| |
| s.expectLines(updatedIssuedInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Qty: mo.Some[float64](2), |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.expectValidationIssueForLine(updatedIssuedInvoice.Lines.OrEmpty()[0], updatedIssuedInvoice.ValidationIssues[0]) |
|
|
| |
| updatedDraftInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: draftInvoice2.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
|
|
| s.populateChildIDsFromParents(&updatedDraftInvoice) |
| s.DebugDumpInvoice("draft invoice2 - 2nd sync", updatedDraftInvoice) |
| s.Len(updatedDraftInvoice.Lines.OrEmpty(), 0) |
| s.Equal(billing.StandardInvoiceStatusDeleted, updatedDraftInvoice.Status) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestGatheringManualEditSync() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1D"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| var updatedLine billing.GatheringLine |
| _, err := s.BillingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{ |
| Invoice: gatheringInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.GatheringInvoice) error { |
| line := s.getGatheringLineByChildID(*invoice, fmt.Sprintf("%s/first-phase/in-advance/v[0]/period[0]", subsView.Subscription.ID)) |
|
|
| price, err := line.Price.AsFlat() |
| s.NoError(err) |
|
|
| price.PaymentTerm = productcatalog.InArrearsPaymentTerm |
| line.Price = *productcatalog.NewPriceFrom(price) |
|
|
| line.ServicePeriod = timeutil.ClosedPeriod{ |
| From: line.ServicePeriod.From.Add(time.Hour), |
| To: line.ServicePeriod.To.Add(time.Hour), |
| } |
| line.InvoiceAt = line.ServicePeriod.To |
| line.ManagedBy = billing.ManuallyManagedLine |
|
|
| updatedLine, err = line.Clone() |
| s.NoError(err) |
| return nil |
| }, |
| }) |
| s.NoError(err) |
|
|
| editedInvoice, err := s.BillingService.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{ |
| Invoice: gatheringInvoice.GetInvoiceID(), |
| Expand: billing.GatheringInvoiceExpands{ |
| billing.GatheringInvoiceExpandLines, |
| billing.GatheringInvoiceExpandDeletedLines, |
| }, |
| }) |
|
|
| s.NoError(err) |
| s.DebugDumpInvoice("edited invoice", editedInvoice) |
| updatedLine = *s.getGatheringLineByChildID(editedInvoice, *updatedLine.ChildUniqueReferenceID) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - after sync", gatheringInvoice) |
|
|
| |
| invoiceLine := s.getGatheringLineByChildID(gatheringInvoice, *updatedLine.ChildUniqueReferenceID) |
| s.True(invoiceLine.GatheringLineBase.Equal(updatedLine.GatheringLineBase), "line should not be updated") |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestSplitLineManualEditSync() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.enableProgressiveBilling() |
|
|
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 12, s.mustParseTime("2024-01-01T10:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: s.APIRequestsTotalFeature.Key, |
| Name: "ubp", |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: lo.ToPtr(s.APIRequestsTotalFeature.Key), |
| FeatureID: lo.ToPtr(s.APIRequestsTotalFeature.ID), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice - pre invoicing", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-15T00:00:00Z")) |
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices, 1) |
| draftInvoice := draftInvoices[0] |
|
|
| s.DebugDumpInvoice("draft invoice", draftInvoice) |
|
|
| var updatedLine *billing.StandardLine |
| editedInvoice, err := s.BillingService.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.StandardInvoice) error { |
| lines := invoice.Lines.OrEmpty() |
| s.Len(lines, 1) |
|
|
| line := lines[0] |
|
|
| line.Name = "test" |
|
|
| updatedLine, err = line.Clone() |
| s.NoError(err) |
| return nil |
| }, |
| }) |
|
|
| s.NoError(err) |
| s.DebugDumpInvoice("edited invoice", editedInvoice) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
| s.NotNil(updatedLine) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-10T00:00:00Z")) |
| _, err = s.SubscriptionService.Cancel(ctx, subsView.Subscription.NamespacedID, subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingImmediate), |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, subsView.Subscription.NamespacedID) |
| s.NoError(err) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
| s.T().Log("-> Subscription canceled") |
|
|
| s.expectNoGatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| resyncedInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: editedInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.DebugDumpInvoice("draft invoice - after sync", resyncedInvoice) |
|
|
| |
| s.Len(resyncedInvoice.Lines.OrEmpty(), 1) |
| resyncedInvoiceLine := resyncedInvoice.Lines.OrEmpty()[0] |
|
|
| |
| s.Equal(resyncedInvoiceLine.StandardLineBase.Name, updatedLine.Name) |
| |
| s.Equal(timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-10T00:00:00Z"), |
| }, resyncedInvoiceLine.Period) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestGatheringManualDeleteSync() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1D"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| var updatedLine billing.GatheringLine |
|
|
| childUniqueReferenceID := fmt.Sprintf("%s/first-phase/in-advance/v[0]/period[0]", subsView.Subscription.ID) |
|
|
| _, err := s.BillingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{ |
| Invoice: gatheringInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.GatheringInvoice) error { |
| line := s.getGatheringLineByChildID(*invoice, childUniqueReferenceID) |
|
|
| line.DeletedAt = lo.ToPtr(clock.Now()) |
|
|
| updatedLine = lo.Must(line.Clone()) |
| return nil |
| }, |
| IncludeDeletedLines: true, |
| }) |
| s.NoError(err) |
|
|
| editedInvoice, err := s.BillingService.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{ |
| Invoice: gatheringInvoice.GetInvoiceID(), |
| Expand: billing.GatheringInvoiceExpands{ |
| billing.GatheringInvoiceExpandLines, |
| billing.GatheringInvoiceExpandDeletedLines, |
| }, |
| }) |
| s.NoError(err) |
|
|
| updatedLineFromEditedInvoice := s.getGatheringLineByChildID(editedInvoice, childUniqueReferenceID) |
| s.NotNil(updatedLineFromEditedInvoice.DeletedAt) |
| s.Equal(billing.ManuallyManagedLine, updatedLineFromEditedInvoice.ManagedBy) |
|
|
| s.DebugDumpInvoice("edited invoice", editedInvoice) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - after sync", gatheringInvoice) |
|
|
| |
| s.expectNoLineWithChildID(gatheringInvoice, *updatedLine.ChildUniqueReferenceID) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestManualIgnoringOfSyncedLines() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices, 1) |
| draftInvoice := draftInvoices[0] |
|
|
| s.DebugDumpInvoice("draft invoice", draftInvoice) |
|
|
| lines, ok := draftInvoice.Lines.Get() |
| s.True(ok) |
| s.Len(lines, 1) |
|
|
| draftLineReferenceID := fmt.Sprintf("%s/first-phase/in-advance/v[0]/period[0]", subsView.Subscription.ID) |
| s.Equal(draftLineReferenceID, *lines[0].ChildUniqueReferenceID) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
| gatheringLines, ok := gatheringInvoice.Lines.Get() |
| s.True(ok) |
| s.Len(gatheringLines, 2) |
|
|
| gatheringLineReferenceID := fmt.Sprintf("%s/first-phase/in-advance/v[0]/period[1]", subsView.Subscription.ID) |
| s.Equal(gatheringLineReferenceID, *gatheringLines[0].ChildUniqueReferenceID) |
|
|
| |
| _, err = s.BillingService.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.StandardInvoice) error { |
| line := s.getStandardLineByChildID(*invoice, draftLineReferenceID) |
|
|
| line.Annotations = models.Annotations{ |
| billing.AnnotationSubscriptionSyncIgnore: true, |
| billing.AnnotationSubscriptionSyncForceContinuousLines: true, |
| } |
|
|
| return nil |
| }, |
| }) |
| s.NoError(err) |
|
|
| var gatheringInvoiceIgnoredLine billing.GatheringLine |
|
|
| _, err = s.BillingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{ |
| Invoice: gatheringInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.GatheringInvoice) error { |
| line := s.getGatheringLineByChildID(*invoice, gatheringLineReferenceID) |
|
|
| line.Annotations = models.Annotations{ |
| billing.AnnotationSubscriptionSyncIgnore: true, |
| billing.AnnotationSubscriptionSyncForceContinuousLines: true, |
| } |
|
|
| gatheringInvoiceIgnoredLine = lo.Must(line.Clone()) |
|
|
| return nil |
| }, |
| }) |
| s.NoError(err) |
|
|
| |
| subsView, err = s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1M")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| |
| draftInvoiceAfterSync, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.DebugDumpInvoice("draft invoice - after sync", draftInvoiceAfterSync) |
|
|
| expectedInvoice := lo.Must(draftInvoice.Clone()) |
| expectedInvoice.Lines = expectedInvoice.Lines.Map(func(line *billing.StandardLine) *billing.StandardLine { |
| if line.ChildUniqueReferenceID != nil && *line.ChildUniqueReferenceID == draftLineReferenceID { |
| line.Annotations = models.Annotations{ |
| billing.AnnotationSubscriptionSyncIgnore: true, |
| billing.AnnotationSubscriptionSyncForceContinuousLines: true, |
| } |
| } |
|
|
| line.DetailedLines = lo.Map(line.DetailedLines, func(child billing.DetailedLine, _ int) billing.DetailedLine { |
| child.FeeLineConfigID = "" |
| return child |
| }) |
|
|
| return line |
| }) |
|
|
| draftInvoiceAfterSync.Lines = draftInvoiceAfterSync.Lines.Map(func(line *billing.StandardLine) *billing.StandardLine { |
| line.DetailedLines = lo.Map(line.DetailedLines, func(child billing.DetailedLine, _ int) billing.DetailedLine { |
| child.FeeLineConfigID = "" |
| return child |
| }) |
| return line |
| }) |
|
|
| if len(expectedInvoice.ValidationIssues) == 0 { |
| expectedInvoice.ValidationIssues = nil |
| } |
|
|
| s.Equal(lo.Must(expectedInvoice.RemoveMetaForCompare()), lo.Must(draftInvoiceAfterSync.RemoveMetaForCompare())) |
|
|
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - after sync", gatheringInvoice) |
|
|
| gatheringInvoiceIgnoredLineAfterSync := s.getGatheringLineByChildID(gatheringInvoice, *gatheringInvoiceIgnoredLine.ChildUniqueReferenceID) |
| s.Equal(lo.Must(gatheringInvoiceIgnoredLine.RemoveMetaForCompare()), lo.Must(gatheringInvoiceIgnoredLineAfterSync.RemoveMetaForCompare())) |
|
|
| |
| deletedGartheringLinereferenceID := fmt.Sprintf("%s/first-phase/in-advance/v[0]/period[2]", subsView.Subscription.ID) |
| for _, line := range gatheringInvoice.Lines.OrEmpty() { |
| if line.ChildUniqueReferenceID != nil && *line.ChildUniqueReferenceID == deletedGartheringLinereferenceID { |
| s.Fail("deleted line should be deleted") |
| } |
| } |
|
|
| |
| updatedGartheringLines := gatheringInvoice.Lines.OrEmpty() |
| s.Len(updatedGartheringLines, 4) |
|
|
| newLineReferenceID := fmt.Sprintf("%s/first-phase/in-advance/v[1]/period[0]", subsView.Subscription.ID) |
| updatedLine := s.getGatheringLineByChildID(gatheringInvoice, newLineReferenceID) |
| s.NotNil(updatedLine) |
|
|
| price, err := updatedLine.Price.AsFlat() |
| s.NoError(err) |
| s.Equal(alpacadecimal.NewFromFloat(10), price.Amount) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestManualIgnoringOfSyncedLinesWhenPeriodChanges() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "marked", |
| Name: "marked", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P3M"), |
| }, |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "non-marked", |
| Name: "non-marked", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P3M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-04-01T00:00:00Z"))) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
| gatheringLines, ok := gatheringInvoice.Lines.Get() |
| s.True(ok) |
| s.Len(gatheringLines, 4) |
|
|
| markedLineReferenceID := fmt.Sprintf("%s/first-phase/marked/v[0]/period[0]", subsView.Subscription.ID) |
| unMarkedLineReferenceID := fmt.Sprintf("%s/first-phase/non-marked/v[0]/period[0]", subsView.Subscription.ID) |
|
|
| |
| _, err := s.BillingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{ |
| Invoice: gatheringInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.GatheringInvoice) error { |
| line := s.getGatheringLineByChildID(*invoice, markedLineReferenceID) |
|
|
| line.Annotations = models.Annotations{ |
| billing.AnnotationSubscriptionSyncIgnore: true, |
| billing.AnnotationSubscriptionSyncForceContinuousLines: true, |
| } |
|
|
| return nil |
| }, |
| }) |
| s.NoError(err) |
|
|
| |
| _, err = s.SubscriptionService.Cancel(ctx, subsView.Subscription.NamespacedID, subscription.Timing{ |
| Custom: lo.ToPtr(s.mustParseTime("2024-02-01T00:00:00Z")), |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, subsView.Subscription.NamespacedID) |
| s.NoError(err) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-04-01T00:00:00Z"))) |
|
|
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - after sync", gatheringInvoice) |
|
|
| |
| markedLine := s.getGatheringLineByChildID(gatheringInvoice, markedLineReferenceID) |
| s.NotNil(markedLine) |
| s.Equal(markedLine.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-04-01T00:00:00Z"), |
| }) |
|
|
| unmarkedLine := s.getGatheringLineByChildID(gatheringInvoice, unMarkedLineReferenceID) |
| s.NotNil(unmarkedLine) |
| s.Equal(unmarkedLine.ServicePeriod, timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestSplitLineManualDeleteSync() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| s.enableProgressiveBilling() |
|
|
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 12, s.mustParseTime("2024-01-01T10:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: s.APIRequestsTotalFeature.Key, |
| Name: "ubp", |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: lo.ToPtr(s.APIRequestsTotalFeature.Key), |
| FeatureID: lo.ToPtr(s.APIRequestsTotalFeature.ID), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.DebugDumpInvoice("gathering invoice - pre invoicing", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-15T00:00:00Z")) |
| draftInvoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| }) |
| s.NoError(err) |
| s.Len(draftInvoices, 1) |
| draftInvoice := draftInvoices[0] |
|
|
| s.DebugDumpInvoice("draft invoice", draftInvoice) |
| s.DebugDumpInvoice("gathering invoice - after invoicing", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
|
|
| var updatedLine *billing.StandardLine |
| editedInvoice, err := s.BillingService.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{ |
| Invoice: draftInvoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceAPIRequest, |
| EditFn: func(invoice *billing.StandardInvoice) error { |
| lines := invoice.Lines.OrEmpty() |
| s.Len(lines, 1) |
|
|
| line := lines[0] |
|
|
| line.DeletedAt = lo.ToPtr(clock.Now()) |
|
|
| updatedLine = lo.Must(line.Clone()) |
| return nil |
| }, |
| }) |
|
|
| s.NoError(err) |
| s.DebugDumpInvoice("edited invoice", editedInvoice) |
| s.DebugDumpInvoice("gathering invoice", s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID)) |
| s.NotNil(updatedLine) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-10T00:00:00Z")) |
| _, err = s.SubscriptionService.Cancel(ctx, subsView.Subscription.NamespacedID, subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingImmediate), |
| }) |
| s.NoError(err) |
|
|
| subsView, err = s.SubscriptionService.GetView(ctx, subsView.Subscription.NamespacedID) |
| s.NoError(err) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-02-01T00:00:00Z"))) |
| s.T().Log("-> Subscription canceled") |
|
|
| s.expectNoGatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
|
|
| resyncedInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ |
| Invoice: editedInvoice.GetInvoiceID(), |
| Expand: billing.StandardInvoiceExpandAll.With(billing.StandardInvoiceExpandDeletedLines), |
| }) |
| s.NoError(err) |
| s.DebugDumpInvoice("draft invoice - after sync", resyncedInvoice) |
|
|
| |
| s.Len(resyncedInvoice.Lines.OrEmpty(), 1) |
|
|
| line := resyncedInvoice.Lines.OrEmpty()[0] |
| s.NotNil(line.DeletedAt) |
| |
| s.Equal(timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-10T00:00:00Z"), |
| }, line.Period) |
|
|
| s.NotNil(line.SplitLineHierarchy) |
| parentGroup := line.SplitLineHierarchy.Group |
| |
| s.Equal(timeutil.ClosedPeriod{ |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-10T00:00:00Z"), |
| }, parentGroup.ServicePeriod) |
| s.Equal(fmt.Sprintf("%s/first-phase/api-requests-total/v[0]/period[0]", subsView.Subscription.ID), *parentGroup.UniqueReferenceID) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestRateCardTaxSync() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| taxConfig := &productcatalog.TaxConfig{ |
| Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), |
| Stripe: &productcatalog.StripeTaxConfig{ |
| Code: "txcd_10000000", |
| }, |
| } |
|
|
| 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, |
| }), |
| TaxConfig: taxConfig, |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1D"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| lines := gatheringInvoice.Lines.OrEmpty() |
| for _, line := range lines { |
| s.Equal(taxConfig, line.TaxConfig) |
| } |
|
|
| |
|
|
| updatedSubsView, err := s.SubscriptionWorkflowService.EditRunning(ctx, subsView.Subscription.NamespacedID, []subscription.Patch{ |
| patch.PatchRemoveItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| }, |
| subscriptionAddItem{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| TaxConfig: taxConfig, |
| BillingCadence: lo.ToPtr(datetime.MustParseDuration(s.T(), "P1D")), |
| }.AsPatch(), |
| }, s.timingImmediate()) |
| s.NoError(err) |
| s.NotNil(updatedSubsView) |
|
|
| s.NoError(s.Service.SyncByView(ctx, updatedSubsView, s.mustParseTime("2024-01-05T12:00:00Z"))) |
|
|
| gatheringInvoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - after edit", gatheringInvoice) |
|
|
| lines = gatheringInvoice.Lines.OrEmpty() |
| for _, line := range lines { |
| s.Equal(taxConfig, line.TaxConfig) |
| } |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceInstantBillingOnSubscriptionCreation() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, s.mustParseTime("2024-01-01T00:00:00Z"))) |
|
|
| |
| invoices, err := s.BillingService.ListInvoices(ctx, billing.ListInvoicesInput{ |
| CustomerID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &s.Customer.ID}}, |
| Expand: billing.InvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 1) |
|
|
| instantInvoice, err := invoices.Items[0].AsStandardInvoice() |
| s.NoError(err) |
|
|
| s.DebugDumpInvoice("instant invoice", instantInvoice) |
|
|
| |
| s.expectLines(instantInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestInAdvanceInstantBillingOnSubscriptionCreationWithSubscriptionStartInFuture() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-02-01T00:00:00Z")) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-20T00:00:00Z")) |
|
|
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, clock.Now())) |
|
|
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Namespaces: []string{s.Namespace}, |
| Customers: []string{s.Customer.ID}, |
| Expand: billing.GatheringInvoiceExpands{ |
| billing.GatheringInvoiceExpandLines, |
| billing.GatheringInvoiceExpandDeletedLines, |
| }, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 1) |
|
|
| gatheringInvoice := invoices.Items[0] |
|
|
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| |
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) expectValidationIssueForLine(line *billing.StandardLine, issue billing.ValidationIssue) { |
| s.Equal(billing.ValidationIssueSeverityWarning, issue.Severity) |
| s.Equal(billing.ImmutableInvoiceHandlingNotSupportedErrorCode, issue.Code) |
| s.Equal(SubscriptionSyncComponentName, issue.Component) |
| s.Equal(fmt.Sprintf("lines/%s", line.ID), issue.Path) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestDiscountSynchronization() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
|
|
| 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(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| Discounts: productcatalog.Discounts{ |
| Percentage: &productcatalog.PercentageDiscount{ |
| Percentage: models.NewPercentage(100), |
| }, |
| }, |
| }, |
| BillingCadence: datetime.MustParseDuration(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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, clock.Now().Add(time.Minute))) |
|
|
| invoices, err := s.BillingService.ListInvoices(ctx, billing.ListInvoicesInput{ |
| CustomerID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &s.Customer.ID}}, |
| Expand: billing.InvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 2) |
|
|
| var gatheringInvoice *billing.GatheringInvoice |
| var instantInvoice *billing.StandardInvoice |
|
|
| for _, invoice := range invoices.Items { |
| if invoice.Type() == billing.InvoiceTypeGathering { |
| invoiceAsGathering, err := invoice.AsGatheringInvoice() |
| s.NoError(err) |
| gatheringInvoice = &invoiceAsGathering |
| continue |
| } |
|
|
| invoiceAsStandard, err := invoice.AsStandardInvoice() |
| s.NoError(err) |
| instantInvoice = &invoiceAsStandard |
| } |
|
|
| s.NotNil(gatheringInvoice, "gathering invoice should be present") |
| s.NotNil(instantInvoice, "instant invoice should be present") |
|
|
| s.DebugDumpInvoice("gathering invoice", *gatheringInvoice) |
| s.DebugDumpInvoice("instant invoice", *instantInvoice) |
|
|
| s.expectLines(*gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(10), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| PeriodMin: 1, |
| PeriodMax: 1, |
| Version: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| |
| s.expectLines(*instantInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| line := instantInvoice.Lines.OrEmpty()[0] |
| s.Len(line.DetailedLines, 1) |
| child := line.DetailedLines[0] |
|
|
| s.Equal(float64(6), child.AmountDiscounts[0].Amount.InexactFloat64()) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestAlignedSubscriptionProratingBehavior() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z")) |
| defer clock.UnFreeze() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| 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(datetime.MustParseDuration(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(datetime.MustParseDuration(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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| } |
|
|
| firstPhase := secondPhase |
| firstPhase.PhaseMeta = s.phaseMeta("first-phase", "P2W") |
|
|
| |
| 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, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Enabled: true, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: []productcatalog.Phase{ |
| firstPhase, |
| secondPhase, |
| }, |
| }, |
| }) |
|
|
| |
| 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) |
|
|
| |
| subView, err = s.SubscriptionService.GetView(ctx, subView.Subscription.NamespacedID) |
| s.NoError(err) |
|
|
| |
| s.NoError(s.Service.SyncByView(ctx, subView, s.mustParseTime("2024-03-01T00:00:00Z"))) |
|
|
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subView.Subscription.ID, []expectedLine{ |
| |
| |
| |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(2.26), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-arrears", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(2.26), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]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: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-01T00:00:00Z"), |
| To: s.mustParseTime("2024-01-15T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-15T00:00:00Z")}), |
| }, |
| |
| |
| |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: "in-advance", |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(2.74), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-15T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-01-15T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: "in-advance", |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: "in-arrears", |
| PeriodMin: 0, |
| PeriodMax: 0, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(2.74), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-15T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "second-phase", |
| ItemKey: "in-arrears", |
| PeriodMin: 1, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{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.0)})), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-01-15T00:00:00Z"), |
| To: s.mustParseTime("2024-02-01T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z"), s.mustParseTime("2024-03-01T00:00:00Z")}), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestSynchronizeSubscriptionPeriodAlgorithmChange() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2025-01-31T00:00:00Z")) |
| defer clock.UnFreeze() |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| }), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| invoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", invoice) |
|
|
| _, err := s.BillingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{ |
| Invoice: invoice.GetInvoiceID(), |
| ChangeSource: billing.ChangeSourceSystem, |
| EditFn: func(invoice *billing.GatheringInvoice) error { |
| line := invoice.Lines.OrEmpty()[0] |
| |
| line.ServicePeriod.From = s.mustParseTime("2025-01-31T00:00:00Z") |
| line.ServicePeriod.To = s.mustParseTime("2025-03-02T00:00:00Z") |
| line.Annotations = models.Annotations{ |
| billing.AnnotationSubscriptionSyncIgnore: true, |
| billing.AnnotationSubscriptionSyncForceContinuousLines: true, |
| } |
|
|
| invoice.Lines = billing.NewGatheringInvoiceLines([]billing.GatheringLine{ |
| line, |
| }) |
| return nil |
| }, |
| }) |
| s.NoError(err) |
|
|
| invoice, err = s.BillingService.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{ |
| Invoice: invoice.GetInvoiceID(), |
| Expand: billing.GatheringInvoiceExpands{ |
| billing.GatheringInvoiceExpandLines, |
| billing.GatheringInvoiceExpandDeletedLines, |
| }, |
| }) |
| s.NoError(err) |
|
|
| s.DebugDumpInvoice("gathering invoice - updated", invoice) |
| s.expectLines(invoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2025-01-31T00:00:00Z"), |
| To: s.mustParseTime("2025-03-02T00:00:00Z"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2025-02-28T00:00:00Z")) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| invoice = s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice - updated", invoice) |
|
|
| s.expectLines(invoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| PeriodMin: 0, |
| PeriodMax: 1, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2025-01-31T00:00:00Z"), |
| To: s.mustParseTime("2025-03-02T00:00:00Z"), |
| }, |
| { |
| From: s.mustParseTime("2025-03-02T00:00:00Z"), |
| To: s.mustParseTime("2025-03-31T00:00:00Z"), |
| }, |
| }, |
|
|
| InvoiceAt: mo.Some([]time.Time{ |
| s.mustParseTime("2025-01-31T00:00:00Z"), |
| s.mustParseTime("2025-03-02T00:00:00Z"), |
| }), |
| }, |
| }) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestDeletedCustomerHandling() { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2025-01-01T00:00:00Z")) |
| defer clock.UnFreeze() |
|
|
| s.MockStreamingConnector.AddSimpleEvent(*s.APIRequestsTotalFeature.MeterSlug, 12, s.mustParseTime("2025-01-01T00:30:00Z")) |
|
|
| subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("first-phase", ""), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: s.APIRequestsTotalFeature.Key, |
| Name: "ubp", |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| }), |
| FeatureKey: lo.ToPtr(s.APIRequestsTotalFeature.Key), |
| FeatureID: lo.ToPtr(s.APIRequestsTotalFeature.ID), |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2025-01-01T01:00:00Z")) |
| subs, err := s.SubscriptionService.Cancel(ctx, subsView.Subscription.NamespacedID, subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingImmediate), |
| }) |
| s.NoError(err) |
| s.NotEmpty(subs) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2025-01-01T02:00:00Z")) |
| err = s.CustomerService.DeleteCustomer(ctx, s.Customer.GetID()) |
| s.NoError(err) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2025-01-01T03:00:00Z")) |
|
|
| |
| subsView, err = s.SubscriptionService.GetView(ctx, subs.NamespacedID) |
| s.NoError(err) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| |
| gatheringInvoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| |
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2025-01-01T00:00:00Z"), |
| To: s.mustParseTime("2025-01-01T01:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2025-01-01T01:00:00Z")}), |
| }, |
| }) |
|
|
| |
| invoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ |
| Customer: s.Customer.GetID(), |
| }) |
| s.NoError(err) |
| s.Len(invoices, 1) |
|
|
| invoice := invoices[0] |
|
|
| s.DebugDumpInvoice("invoice", invoice) |
| |
| |
| |
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: s.APIRequestsTotalFeature.Key, |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(5), |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2025-01-01T00:00:00Z"), |
| To: s.mustParseTime("2025-01-01T01:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2025-01-01T01:00:00Z")}), |
| }, |
| }) |
|
|
| |
| s.Equal(billing.StandardInvoiceStatusDraftWaitingAutoApproval, invoice.Status) |
| s.Equal(float64(5*12), invoice.Totals.Total.InexactFloat64()) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestFirstDayOfMonthBillingForSubPeriodLength() { |
| s.T().Skip("Skipping test for now") |
|
|
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2025-10-15T00:00:00Z")) |
| defer clock.UnFreeze() |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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: 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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| 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(clock.Now()), |
| }, |
| Name: "subs-1", |
| }, |
| Namespace: s.Namespace, |
| CustomerID: s.Customer.ID, |
| BillingAnchor: lo.ToPtr(s.mustParseTime("2025-10-01T00:00:00Z")), |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-20T00:00:00Z")) |
|
|
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, clock.Now())) |
|
|
| invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ |
| Customers: []string{s.Customer.ID}, |
| Expand: billing.GatheringInvoiceExpandAll, |
| }) |
| s.NoError(err) |
| s.Len(invoices.Items, 1) |
|
|
| gatheringInvoice := invoices.Items[0] |
|
|
| s.DebugDumpInvoice("gathering invoice", gatheringInvoice) |
|
|
| s.expectLines(gatheringInvoice, subsView.Subscription.ID, []expectedLine{ |
| { |
| Matcher: recurringLineMatcher{ |
| PhaseKey: "first-phase", |
| ItemKey: "in-advance", |
| }, |
| Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ |
| Amount: alpacadecimal.NewFromFloat(6), |
| PaymentTerm: productcatalog.InAdvancePaymentTerm, |
| })), |
| Periods: []timeutil.ClosedPeriod{ |
| { |
| From: s.mustParseTime("2024-02-01T00:00:00Z"), |
| To: s.mustParseTime("2024-03-01T00:00:00Z"), |
| }, |
| }, |
| InvoiceAt: mo.Some([]time.Time{s.mustParseTime("2024-02-01T00:00:00Z")}), |
| }, |
| }) |
|
|
| s.NoError(s.Service.SyncByView(ctx, subsView, clock.Now())) |
|
|
| invoice := s.gatheringInvoice(ctx, s.Namespace, s.Customer.ID) |
| s.DebugDumpInvoice("gathering invoice", invoice) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestSyncStateUpdateNoBillables() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2025-10-15T00:00:00Z")) |
| defer clock.UnFreeze() |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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: 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", |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| 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(clock.Now()), |
| }, |
| Name: "subs-1", |
| }, |
| Namespace: s.Namespace, |
| CustomerID: s.Customer.ID, |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-20T00:00:00Z")) |
|
|
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, clock.Now())) |
|
|
| syncStates, err := s.Adapter.GetSyncStates(ctx, subscriptionsync.GetSyncStatesInput{ |
| { |
| Namespace: subsView.Subscription.Namespace, |
| ID: subsView.Subscription.ID, |
| }, |
| }) |
|
|
| require.NoError(s.T(), err) |
| require.Len(s.T(), syncStates, 1) |
|
|
| s.Equal(subscriptionsync.SyncState{ |
| SubscriptionID: models.NamespacedID{ |
| Namespace: subsView.Subscription.Namespace, |
| ID: subsView.Subscription.ID, |
| }, |
| HasBillables: false, |
| SyncedAt: clock.Now().UTC(), |
| NextSyncAfter: nil, |
| }, syncStates[0]) |
| } |
|
|
| func (s *SubscriptionHandlerTestSuite) TestSyncStateUpdateWithFreePhaseActiveInTheFuture() { |
| ctx := s.T().Context() |
| clock.FreezeTime(s.mustParseTime("2025-10-15T00:00:00Z")) |
| defer clock.UnFreeze() |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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: false, |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| Phases: []productcatalog.Phase{ |
| { |
| PhaseMeta: s.phaseMeta("free-phase", "P2M"), |
| RateCards: productcatalog.RateCards{ |
| &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "in-advance", |
| Name: "in-advance", |
| }, |
| BillingCadence: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| { |
| PhaseMeta: s.phaseMeta("paid-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: datetime.MustParseDuration(s.T(), "P1M"), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| 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(clock.Now()), |
| }, |
| Name: "subs-1", |
| }, |
| Namespace: s.Namespace, |
| CustomerID: s.Customer.ID, |
| }, subscriptionPlan) |
|
|
| s.NoError(err) |
| s.NotNil(subsView) |
|
|
| clock.FreezeTime(s.mustParseTime("2024-01-20T00:00:00Z")) |
|
|
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, clock.Now())) |
|
|
| syncStates, err := s.Adapter.GetSyncStates(ctx, subscriptionsync.GetSyncStatesInput{ |
| { |
| Namespace: subsView.Subscription.Namespace, |
| ID: subsView.Subscription.ID, |
| }, |
| }) |
|
|
| require.NoError(s.T(), err) |
| require.Len(s.T(), syncStates, 1) |
|
|
| s.Equal(subscriptionsync.SyncState{ |
| SubscriptionID: models.NamespacedID{ |
| Namespace: subsView.Subscription.Namespace, |
| ID: subsView.Subscription.ID, |
| }, |
| HasBillables: true, |
| SyncedAt: clock.Now().UTC(), |
| NextSyncAfter: lo.ToPtr(s.mustParseTime("2025-12-15T00:00:00Z")), |
| }, syncStates[0]) |
|
|
| |
| clock.FreezeTime(s.mustParseTime("2025-12-15T01:00:00Z")) |
| s.NoError(s.Service.SyncByViewAndInvoiceCustomer(ctx, subsView, clock.Now())) |
|
|
| syncStates, err = s.Adapter.GetSyncStates(ctx, subscriptionsync.GetSyncStatesInput{ |
| { |
| Namespace: subsView.Subscription.Namespace, |
| ID: subsView.Subscription.ID, |
| }, |
| }) |
|
|
| require.NoError(s.T(), err) |
| require.Len(s.T(), syncStates, 1) |
|
|
| s.Equal(subscriptionsync.SyncState{ |
| SubscriptionID: models.NamespacedID{ |
| Namespace: subsView.Subscription.Namespace, |
| ID: subsView.Subscription.ID, |
| }, |
| HasBillables: true, |
| SyncedAt: clock.Now().UTC(), |
| NextSyncAfter: lo.ToPtr(s.mustParseTime("2026-01-15T00:00:00Z")), |
| }, syncStates[0]) |
| } |
|
|