File size: 15,480 Bytes
16cdcb7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
package service

import (
	"context"
	"fmt"

	"github.com/samber/lo"

	"github.com/openmeterio/openmeter/openmeter/entitlement"
	"github.com/openmeterio/openmeter/openmeter/subscription"
	"github.com/openmeterio/openmeter/pkg/convert"
	"github.com/openmeterio/openmeter/pkg/framework/transaction"
	"github.com/openmeterio/openmeter/pkg/models"
)

// Sync manages the synchronization of a subscription with a new spec.
// It consists of 3 steps:
// 1. Remove anything that's changed or got removed
// 2. Create anything that's been changed
// 3. Create anything that's new
//
// Some remarks:
// 1. Change comparison is done on the relevant create inputs.
// 2. Things being deleted are marked via a `touched` map.
//
// TODO: localize error so phase and item keys are always included (alongside subscription reference)
// TODO (OM-1074): clean up this control flow
func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, newSpec subscription.SubscriptionSpec) (subscription.Subscription, error) {
	return transaction.Run(ctx, s.TransactionManager, func(ctx context.Context) (subscription.Subscription, error) {
		var def subscription.Subscription

		// Some sanity checks for good measure
		if view.Subscription.CustomerId != newSpec.CustomerId {
			return def, fmt.Errorf("cannot change customer id")
		}
		if !view.Subscription.PlanRef.NilEqual(newSpec.Plan) {
			return def, fmt.Errorf("cannot change plan")
		}
		if !view.Subscription.ActiveFrom.Equal(newSpec.ActiveFrom) {
			return def, fmt.Errorf("cannot change subscription start")
		}
		if view.Subscription.SettlementMode != newSpec.SettlementMode {
			return def, fmt.Errorf("cannot change settlement mode")
		}

		dirty := make(touched)

		// Let's make sure the Subscription Cadence is up to date
		if !view.Subscription.CadencedModel.Equal(models.CadencedModel{ActiveFrom: newSpec.ActiveFrom, ActiveTo: newSpec.ActiveTo}) {
			_, err := s.SubscriptionRepo.SetEndOfCadence(ctx, view.Subscription.NamespacedID, newSpec.ActiveTo)
			if err != nil {
				return def, fmt.Errorf("failed to set end of cadence: %w", err)
			}
		}

		// 1. Let's remove anything that's changed or got removed
		newSortedPhaseSpecs := newSpec.GetSortedPhases()
		for _, currentPhaseView := range view.Phases {
			// Let's try find a matching phase in the new spec
			matchingPhaseFromNewSpec, found := lo.Find(newSortedPhaseSpecs, func(s *subscription.SubscriptionPhaseSpec) bool {
				return s.PhaseKey == currentPhaseView.SubscriptionPhase.Key
			})

			// If there's no equivalent found in the current spec, we need to delete the phase
			if !found {
				if err := s.deletePhase(ctx, currentPhaseView); err != nil {
					return def, fmt.Errorf("failed to delete phase: %w", err)
				}

				dirty.mark(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key))

				// There's nothing more to be done for this phase, so lets skip to the next one
				continue
			}

			// sanity check
			if matchingPhaseFromNewSpec == nil {
				return def, fmt.Errorf("failed to find matching phase in new spec but no error was returned")
			}

			// Let's get the cadence of the current phase
			cadenceOfCurrentPhaseBasedOnSpec, err := view.Spec.GetPhaseCadence(currentPhaseView.SubscriptionPhase.Key)
			if err != nil {
				return def, fmt.Errorf("failed to get cadence for current phase %s: %w", currentPhaseView.SubscriptionPhase.Key, err)
			}

			// Let's get the cadence of the new phase
			cadenceOfNewPhaseBasedOnSpec, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey)
			if err != nil {
				return def, fmt.Errorf("failed to get cadence for new phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err)
			}

			// Lets figure out when the new phase should start
			newPhaseStartTime, _ := matchingPhaseFromNewSpec.StartAfter.AddTo(view.Subscription.ActiveFrom)

			curr := currentPhaseView.Spec.ToCreateSubscriptionPhaseEntityInput(view.Subscription, currentPhaseView.SubscriptionPhase.ActiveFrom)
			new := matchingPhaseFromNewSpec.ToCreateSubscriptionPhaseEntityInput(view.Subscription, newPhaseStartTime)

			// If the phase has any changes, we need to recreate it. That means also all sub-resources of it have to be relinked.
			if !curr.Equal(new) {
				// This means deleting the phase with all its sub-resources
				if err := s.deletePhase(ctx, currentPhaseView); err != nil {
					return def, fmt.Errorf("failed to delete phase: %w", err)
				}

				dirty.mark(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key))

				// The phase is deleted, there's nothing more to be done
				continue
			}

			// Sanity check, the current phase cannot be dirty
			if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) {
				return def, fmt.Errorf("current phase is dirty but should not be")
			}

			// Now let's iterate through all items in the phase
			for currentItemViewsKey, currentItemViews := range currentPhaseView.ItemsByKey {
				// Let's try find a matching item in the new spec
				// Here as we do an update, we rely on the previously verified integrity of both view and spec
				// Due to this, we use a simple matching based on the index of the item under the given key
				matchingItemsByKeyFromNewSpec, found := matchingPhaseFromNewSpec.ItemsByKey[currentItemViewsKey]

				// If no matching key is found in the new spec lets delete everything
				if !found || len(matchingItemsByKeyFromNewSpec) == 0 {
					for _, currentItemView := range currentItemViews {
						if err := s.deleteItem(ctx, currentItemView); err != nil {
							return def, fmt.Errorf("failed to delete item: %w", err)
						}

						dirty.mark(subscription.NewItemPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey))
					}

					// There's nothing more to be done for this item(key), so lets skip to the next one
					continue
				}

				for currentItemIdx, currentItemView := range currentItemViews {
					// Let's get the item with the same index from the new spec
					if currentItemIdx >= len(matchingItemsByKeyFromNewSpec) {
						// Let's delete the item as it's not present in the new spec

						if err := s.deleteItem(ctx, currentItemView); err != nil {
							return def, fmt.Errorf("failed to delete item: %w", err)
						}

						dirty.mark(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx))

						// There's nothing more to be done for this item, so lets skip to the next one
						continue
					}

					matchingItemFromNewSpec := matchingItemsByKeyFromNewSpec[currentItemIdx]

					// First, let's check if the item itself needs to be changed
					curr, err := currentItemView.Spec.ToCreateSubscriptionItemEntityInput(
						currentPhaseView.SubscriptionPhase.NamespacedID,
						cadenceOfCurrentPhaseBasedOnSpec,
						convert.SafeDeRef(currentItemView.Entitlement, func(s subscription.SubscriptionEntitlement) *entitlement.Entitlement {
							return &s.Entitlement.Entitlement
						}),
					)
					if err != nil {
						return def, fmt.Errorf("failed to convert item to entity input: %w", err)
					}

					// Here we don't preamptively know all the properties but fortunately all we need to know is whether they'd change or not
					// We're prepopulating changing fields with invalid values, which is a lie and a bad method, but it's necessary for now due to the hard linking

					newPhaseID := currentPhaseView.SubscriptionPhase.NamespacedID
					if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) {
						newPhaseID = impossibleNamespacedId
					}

					newOnlyForComparisonWithInvalidProperties, err := matchingItemFromNewSpec.ToCreateSubscriptionItemEntityInput(
						newPhaseID,
						cadenceOfNewPhaseBasedOnSpec,
						// Without the "new" entitlement already present we cannot properly compare the two create inputs.
						// To work around this, we'll reuse the current entitlement for comparison.
						// This won't cause an issue as all relevant properties of the entitlement are specced on the Item (as part of RateCard)
						// FIXME: This is a lie
						convert.SafeDeRef(currentItemView.Entitlement, func(s subscription.SubscriptionEntitlement) *entitlement.Entitlement {
							return &s.Entitlement.Entitlement
						}),
					)
					if err != nil {
						return def, fmt.Errorf("failed to convert item to entity input: %w", err)
					}

					doesItemNeedToBeChanged := !curr.Equal(newOnlyForComparisonWithInvalidProperties)

					if doesItemNeedToBeChanged {
						// This means deleting the item with all its sub-resources
						if err := s.deleteItem(ctx, currentItemView); err != nil {
							return def, fmt.Errorf("failed to delete item: %w", err)
						}

						dirty.mark(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx))

						// There's nothing more to be done here, so lets skip to the next one
						continue
					}
				}
			}
		}

		// 2. Let's create anything that's been changed
		for _, currentPhaseView := range view.Phases {
			// Let's try find a matching phase in the new spec
			matchingPhaseFromNewSpec, found := lo.Find(newSortedPhaseSpecs, func(s *subscription.SubscriptionPhaseSpec) bool {
				return s.PhaseKey == currentPhaseView.SubscriptionPhase.Key
			})

			if !found {
				// If the phase wasn't found there's nothing to create
				continue
			}

			// Sanity check
			if matchingPhaseFromNewSpec == nil {
				return def, fmt.Errorf("failed to find matching phase in new spec but no error was returned")
			}

			newPhaseCadence, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey)
			if err != nil {
				return def, fmt.Errorf("failed to get cadence for phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err)
			}

			// If the phase got deleted, we can create it as a whole
			if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) {
				if _, err := s.createPhase(ctx, view.Customer, *matchingPhaseFromNewSpec, view.Subscription, newPhaseCadence); err != nil {
					return def, fmt.Errorf("failed to create phase: %w", err)
				}

				// There's nothing more to be done for this phase, so lets skip to the next one
				continue
			}

			// Now let's check each of the items in the phase
			for currentItemViewsKey, currentItemViews := range currentPhaseView.ItemsByKey {
				// Let's try find a matching item in the new spec
				// Here as we do an update, we rely on the previously verified integrity of both view and spec
				// Due to this, we use a simple matching based on the index of the item under the given key
				matchingItemsByKeyFromNewSpec, found := matchingPhaseFromNewSpec.ItemsByKey[currentItemViewsKey]
				if !found {
					// If the item wasn't found there's nothing to create
					continue
				}

				for currentItemIdx, currentItemView := range currentItemViews {
					// Let's get the item with the same index from the new spec
					if currentItemIdx >= len(matchingItemsByKeyFromNewSpec) {
						// We went out of bounds, these items are not present in the new spec so we can just break

						break
					}

					matchingItemFromNewSpec := matchingItemsByKeyFromNewSpec[currentItemIdx]

					// If the item got deleted, we can create it as a whole
					if dirty.isTouched(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx)) {
						if _, err := s.createItem(ctx, createItemOptions{
							cust:         view.Customer,
							sub:          view.Subscription,
							phase:        currentPhaseView.SubscriptionPhase,
							phaseCadence: newPhaseCadence,
							itemSpec:     *matchingItemFromNewSpec,
						}); err != nil {
							return def, fmt.Errorf("failed to create item: %w", err)
						}

						// There's nothing more to be done for this item, so lets skip to the next one
						continue
					}
				}
			}
		}

		// 3. Finally, let's create anything that's new
		for _, phase := range newSpec.GetSortedPhases() {
			// Sanity check
			if phase == nil {
				return def, fmt.Errorf("phase is nil")
			}

			// Let's see if the phase was present in the current view
			matchingPhaseInCurrentView, foundMatchingPhaseInCurrentView := lo.Find(view.Phases, func(p subscription.SubscriptionPhaseView) bool {
				return p.SubscriptionPhase.Key == phase.PhaseKey
			})

			if !foundMatchingPhaseInCurrentView {
				phaseCadence, err := newSpec.GetPhaseCadence(phase.PhaseKey)
				if err != nil {
					return def, fmt.Errorf("failed to get cadence for phase %s: %w", phase.PhaseKey, err)
				}

				if _, err := s.createPhase(ctx, view.Customer, *phase, view.Subscription, phaseCadence); err != nil {
					return def, fmt.Errorf("failed to create phase: %w", err)
				}
				continue
			}

			// Now lets check all the items in the phase
			for key, itemsByKey := range phase.ItemsByKey {
				matchingItemsByKeyInCurrentView, foundMatchingItemsByKeyInCurrentView := matchingPhaseInCurrentView.ItemsByKey[key]

				for itemIdx, item := range itemsByKey {
					phaseCadence, err := newSpec.GetPhaseCadence(phase.PhaseKey)
					if err != nil {
						return def, fmt.Errorf("failed to get cadence for phase %s: %w", phase.PhaseKey, err)
					}

					// If we didn't find a matching key in the current view, we need to create the item
					if !foundMatchingItemsByKeyInCurrentView {
						if _, err := s.createItem(ctx, createItemOptions{
							cust:         view.Customer,
							sub:          view.Subscription,
							phase:        matchingPhaseInCurrentView.SubscriptionPhase,
							phaseCadence: phaseCadence,
							itemSpec:     *item,
						}); err != nil {
							return def, fmt.Errorf("failed to create item: %w", err)
						}

						// There's nothing left to do for this item
						continue
					} else if itemIdx >= len(matchingItemsByKeyInCurrentView) {
						// If there's a matching key, then in the previous step we've taken care of all indexes
						// present in the current phase

						// The rest we create
						if _, err := s.createItem(ctx, createItemOptions{
							cust:         view.Customer,
							sub:          view.Subscription,
							phase:        matchingPhaseInCurrentView.SubscriptionPhase,
							phaseCadence: phaseCadence,
							itemSpec:     *item,
						}); err != nil {
							return def, fmt.Errorf("failed to create item: %w", err)
						}
					}
				}
			}
		}

		// 4. Finally we're done with syncing everything, we should just re-fetch the subscription
		return s.Get(ctx, view.Subscription.NamespacedID)
	})
}

// touched is a map of touched paths (honoring sub-resource relationships)
type touched map[subscription.SpecPath]bool

// Mark a given path as touched
func (t touched) mark(key subscription.SpecPath) {
	t[key] = true
}

// Check if a given path has been touched
// If path X has been touched, then all sub-resources of X have been touched
func (t touched) isTouched(key subscription.SpecPath) bool {
	for k := range t {
		// IsParentOf check returns true for identity as well
		if k.IsParentOf(key) {
			return true
		}
	}
	return false
}

// NewItemVersionPath returns an invalid PatchPath thats still usable for IsParentOf checks
// FIXME: this is a hack. For instance, is featureKey were to contain `/` it would completely break (though that exact scenario is otherwise prohibited)

// an ID that can never occur in normal control flow
var impossibleNamespacedId = models.NamespacedID{
	ID:        "impossible",
	Namespace: "impossible",
}