From 2455de99ff4d78108a8b9a4011016932b5810d90 Mon Sep 17 00:00:00 2001 From: emmyni <44761757+emmyni@users.noreply.github.com> Date: Tue, 29 Nov 2022 11:47:51 -0500 Subject: [PATCH] [MM-47565]: In product true up for yearly products (#21704) --- app/app_iface.go | 2 + app/cloud.go | 17 ++++++ app/opentracing/opentracing_layer.go | 22 ++++++++ app/user.go | 5 ++ app/user_test.go | 82 ++++++++++++++++++++++++++++ einterfaces/cloud.go | 3 + einterfaces/mocks/CloudInterface.go | 46 ++++++++++++++++ model/cloud.go | 53 +++++++++++++----- 8 files changed, 215 insertions(+), 15 deletions(-) diff --git a/app/app_iface.go b/app/app_iface.go index e3c02a0d80..0ce3e27e33 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -83,6 +83,8 @@ type AppIface interface { ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.UserPatch, sysadmin bool) (*model.User, *model.AppError) // ConvertUserToBot converts a user to bot. ConvertUserToBot(user *model.User) (*model.Bot, *model.AppError) + // Create/ Update a subscription history event + SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) // CreateBot creates the given bot and corresponding user. CreateBot(c request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) // CreateChannelScheme creates a new Scheme of scope channel and assigns it to the channel. diff --git a/app/cloud.go b/app/cloud.go index e4f19ac46b..fd2d4fb23e 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -230,3 +230,20 @@ func (a *App) SendNoCardPaymentFailedEmail() *model.AppError { } return nil } + +// Create/ Update a subscription history event +func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { + license := a.Srv().License() + + // No need to create a Subscription History Event if the license isn't cloud + if !license.IsCloud() { + return nil, nil + } + + // Get user count + userCount, err := a.Srv().Store().User().Count(model.UserCountOptions{}) + if err != nil { + return nil, err + } + return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount)) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 63a3389735..311543fb78 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -15441,6 +15441,28 @@ func (a *OpenTracingAppLayer) SendPaymentFailedEmail(failedPayment *model.Failed return resultVar0 } +func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendSubscriptionHistoryEvent") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SendSubscriptionHistoryEvent(userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestPushNotification") diff --git a/app/user.go b/app/user.go index 206a030605..ddc329ddc3 100644 --- a/app/user.go +++ b/app/user.go @@ -318,6 +318,11 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m }) } + _, cwsErr := a.SendSubscriptionHistoryEvent(ruser.Id) + if cwsErr != nil { + c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(cwsErr)) + } + return ruser, nil } diff --git a/app/user_test.go b/app/user_test.go index 64aa833295..80a9ffd77d 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -1852,3 +1852,85 @@ func TestIsFirstAdmin(t *testing.T) { require.True(t, isFirstAdmin) }) } + +func TestSendSubscriptionHistoryEvent(t *testing.T) { + cloudProduct := &model.Product{ + ID: "prod_test1", + Name: "name1", + Description: "description1", + PricePerSeat: 1000, + SKU: "sku1", + PriceID: "price_id1", + Family: "family1", + RecurringInterval: "year", + BillingScheme: "billing_scheme1", + CrossSellsTo: "prod_test2", + } + + subscription := &model.Subscription{ + ID: "MySubscriptionID", + CustomerID: "MyCustomer", + ProductID: "SomeProductId", + AddOns: []string{}, + StartAt: 1000000000, + EndAt: 2000000000, + CreateAt: 1000000000, + Seats: 10, + DNS: "some.dns.server", + IsPaidTier: "false", + } + + subscriptionHistory := &model.SubscriptionHistory{ + ID: "sub_history", + SubscriptionID: "MySubscriptionID", + Seats: 10, + CreateAt: 1000000000, + } + + t.Run("Should not create SubscriptionHistoryEvent if the license is not cloud", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("")) + + userID := "123" + + subscriptionHistoryEvent, err := th.App.SendSubscriptionHistoryEvent(userID) + require.NoError(t, err) + require.Nil(t, subscriptionHistoryEvent) + }) + + t.Run("Should create SubscriptionHistoryEvent if the license is cloud and the product is yearly", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := mocks.CloudInterface{} + + // mock the cloud functions + cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil) + cloud.Mock.On("GetCloudProduct", mock.Anything, mock.Anything).Return(cloudProduct, nil) + cloud.Mock.On("CreateOrUpdateSubscriptionHistoryEvent", mock.Anything, mock.Anything).Return(subscriptionHistory, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + // Mock to get the user count + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockUserStore := storemocks.UserStore{} + mockUserStore.On("Count", mock.Anything).Return(int64(10), nil) + + mockStore.On("User").Return(&mockUserStore) + + userID := "123" + + subscriptionHistoryEvent, err := th.App.SendSubscriptionHistoryEvent(userID) + require.NoError(t, err) + require.Equal(t, subscription.ID, subscriptionHistoryEvent.SubscriptionID, "subscription ID doesn't match") + require.Equal(t, 10, subscriptionHistoryEvent.Seats, "Number of seats doesn't match") + }) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 854b54fdf5..36b302a321 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -8,6 +8,7 @@ import ( ) type CloudInterface interface { + GetCloudProduct(userID string, productID string) (*model.Product, error) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) @@ -30,5 +31,7 @@ type CloudInterface interface { // GetLicenseRenewalStatus checks on the portal whether it is possible to use token to renew a license GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + + CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 3157ecfc44..be0e1801d8 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -74,6 +74,29 @@ func (_m *CloudInterface) CreateCustomerPayment(userID string) (*model.StripeSet return r0, r1 } +// CreateOrUpdateSubscriptionHistoryEvent provides a mock function with given fields: userID, userCount +func (_m *CloudInterface) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) { + ret := _m.Called(userID, userCount) + + var r0 *model.SubscriptionHistory + if rf, ok := ret.Get(0).(func(string, int) *model.SubscriptionHistory); ok { + r0 = rf(userID, userCount) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SubscriptionHistory) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int) error); ok { + r1 = rf(userID, userCount) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCloudCustomer provides a mock function with given fields: userID func (_m *CloudInterface) GetCloudCustomer(userID string) (*model.CloudCustomer, error) { ret := _m.Called(userID) @@ -120,6 +143,29 @@ func (_m *CloudInterface) GetCloudLimits(userID string) (*model.ProductLimits, e return r0, r1 } +// GetCloudProduct provides a mock function with given fields: userID, productID +func (_m *CloudInterface) GetCloudProduct(userID string, productID string) (*model.Product, error) { + ret := _m.Called(userID, productID) + + var r0 *model.Product + if rf, ok := ret.Get(0).(func(string, string) *model.Product); ok { + r0 = rf(userID, productID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Product) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userID, productID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCloudProducts provides a mock function with given fields: userID, includeLegacyProducts func (_m *CloudInterface) GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) { ret := _m.Called(userID, includeLegacyProducts) diff --git a/model/cloud.go b/model/cloud.go index 1d33c57146..bbc3c8f32f 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -140,21 +140,36 @@ type PaymentMethod struct { // Subscription model represents a subscription on the system. type Subscription struct { - ID string `json:"id"` - CustomerID string `json:"customer_id"` - ProductID string `json:"product_id"` - AddOns []string `json:"add_ons"` - StartAt int64 `json:"start_at"` - EndAt int64 `json:"end_at"` - CreateAt int64 `json:"create_at"` - Seats int `json:"seats"` - Status string `json:"status"` - DNS string `json:"dns"` - IsPaidTier string `json:"is_paid_tier"` - LastInvoice *Invoice `json:"last_invoice"` - IsFreeTrial string `json:"is_free_trial"` - TrialEndAt int64 `json:"trial_end_at"` - DelinquentSince *int64 `json:"delinquent_since"` + ID string `json:"id"` + CustomerID string `json:"customer_id"` + ProductID string `json:"product_id"` + AddOns []string `json:"add_ons"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + CreateAt int64 `json:"create_at"` + Seats int `json:"seats"` + Status string `json:"status"` + DNS string `json:"dns"` + IsPaidTier string `json:"is_paid_tier"` + LastInvoice *Invoice `json:"last_invoice"` + IsFreeTrial string `json:"is_free_trial"` + TrialEndAt int64 `json:"trial_end_at"` + DelinquentSince *int64 `json:"delinquent_since"` + OriginallyLicensedSeats int `json:"originally_licensed_seats"` +} + +// Subscription History model represents true up event in a yearly subscription +type SubscriptionHistory struct { + ID string `json:"id"` + SubscriptionID string `json:"subscription_id"` + Seats int `json:"seats"` + CreateAt int64 `json:"create_at"` +} + +type SubscriptionHistoryChange struct { + SubscriptionID string `json:"subscription_id"` + Seats int `json:"seats"` + CreateAt int64 `json:"create_at"` } // GetWorkSpaceNameFromDNS returns the work space name. For example from test.mattermost.cloud.com, it returns test @@ -258,3 +273,11 @@ type ProductLimits struct { Messages *MessagesLimits `json:"messages,omitempty"` Teams *TeamsLimits `json:"teams,omitempty"` } + +func (p *Product) IsYearly() bool { + return p.RecurringInterval == RecurringIntervalYearly +} + +func (p *Product) IsMonthly() bool { + return p.RecurringInterval == RecurringIntervalMonthly +}