Skip to content

Commit 3eca364

Browse files
authored
Fix customer subject validation (#2385)
1 parent 1bf7d32 commit 3eca364

File tree

4 files changed

+130
-8
lines changed

4 files changed

+130
-8
lines changed

app/common/subscription.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func NewSubscriptionServices(
9292
Logger: logger.With("subsystem", "subscription.change.service"),
9393
})
9494

95-
validator, err := subscriptioncustomer.NewValidator(subscriptionService)
95+
validator, err := subscriptioncustomer.NewValidator(subscriptionService, customerService)
9696
if err != nil {
9797
return SubscriptionServiceWithWorkflow{}, err
9898
}

openmeter/subscription/service/service.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (s *service) Create(ctx context.Context, namespace string, spec subscriptio
5757
return def, fmt.Errorf("spec is invalid: %w", err)
5858
}
5959

60-
// Fetch the customer
60+
// Fetch the customer & validate the customer
6161
cust, err := s.CustomerService.GetCustomer(ctx, customer.GetCustomerInput{
6262
Namespace: namespace,
6363
ID: spec.CustomerId,
@@ -70,9 +70,21 @@ func (s *service) Create(ctx context.Context, namespace string, spec subscriptio
7070
return def, fmt.Errorf("customer is nil")
7171
}
7272

73-
if cust.Currency != nil {
74-
if string(*cust.Currency) != string(spec.Currency) {
75-
return def, models.NewGenericValidationError(fmt.Errorf("currency mismatch: customer currency is %s, but subscription currency is %s", *cust.Currency, spec.Currency))
73+
if _, err := cust.UsageAttribution.GetSubjectKey(); err != nil {
74+
if spec.HasEntitlements() {
75+
return def, models.NewGenericValidationError(errors.New("customer has no subject but subscription has entitlements"))
76+
}
77+
78+
if spec.HasMeteredBillables() {
79+
return def, models.NewGenericValidationError(errors.New("customer has no subject but subscription has metered billables"))
80+
}
81+
}
82+
83+
if spec.HasBillables() {
84+
if cust.Currency != nil {
85+
if string(*cust.Currency) != string(spec.Currency) {
86+
return def, models.NewGenericValidationError(fmt.Errorf("currency mismatch: customer currency is %s, but subscription currency is %s", *cust.Currency, spec.Currency))
87+
}
7688
}
7789
}
7890

@@ -136,8 +148,8 @@ func (s *service) Create(ctx context.Context, namespace string, spec subscriptio
136148
return def, err
137149
}
138150

139-
// Let's set the customer's currency to the subscription currency (if not already set)
140-
if cust.Currency == nil {
151+
// Let's set the customer's currency to the subscription currency for paid subscriptions (if not already set)
152+
if cust.Currency == nil && spec.HasBillables() {
141153
if _, err := s.CustomerService.UpdateCustomer(ctx, customer.UpdateCustomerInput{
142154
CustomerID: cust.GetID(),
143155
CustomerMutate: customer.CustomerMutate{
@@ -194,6 +206,37 @@ func (s *service) Update(ctx context.Context, subscriptionID models.NamespacedID
194206
return def, err
195207
}
196208

209+
// Fetch the customer & validate the customer
210+
cust, err := s.CustomerService.GetCustomer(ctx, customer.GetCustomerInput{
211+
Namespace: view.Subscription.Namespace,
212+
ID: view.Subscription.CustomerId,
213+
})
214+
if err != nil {
215+
return def, err
216+
}
217+
218+
if cust == nil {
219+
return def, fmt.Errorf("customer is nil")
220+
}
221+
222+
if _, err := cust.UsageAttribution.GetSubjectKey(); err != nil {
223+
if newSpec.HasEntitlements() {
224+
return def, models.NewGenericValidationError(errors.New("customer has no subject but subscription has entitlements"))
225+
}
226+
227+
if newSpec.HasMeteredBillables() {
228+
return def, models.NewGenericValidationError(errors.New("customer has no subject but subscription has metered billables"))
229+
}
230+
}
231+
232+
if newSpec.HasBillables() {
233+
if cust.Currency != nil {
234+
if string(*cust.Currency) != string(newSpec.Currency) {
235+
return def, models.NewGenericValidationError(fmt.Errorf("currency mismatch: customer currency is %s, but subscription currency is %s", *cust.Currency, newSpec.Currency))
236+
}
237+
}
238+
}
239+
197240
return transaction.Run(ctx, s.TransactionManager, func(ctx context.Context) (subscription.Subscription, error) {
198241
subs, err := s.sync(ctx, view, newSpec)
199242
if err != nil {

openmeter/subscription/subscriptionspec.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,24 @@ func (s *SubscriptionSpec) GetCurrentPhaseAt(t time.Time) (*SubscriptionPhaseSpe
151151
return current, true
152152
}
153153

154+
func (s *SubscriptionSpec) HasEntitlements() bool {
155+
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
156+
return p.HasEntitlements()
157+
})
158+
}
159+
160+
func (s *SubscriptionSpec) HasBillables() bool {
161+
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
162+
return p.HasBillables()
163+
})
164+
}
165+
166+
func (s *SubscriptionSpec) HasMeteredBillables() bool {
167+
return lo.SomeBy(lo.Values(s.Phases), func(p *SubscriptionPhaseSpec) bool {
168+
return p.HasMeteredBillables()
169+
})
170+
}
171+
154172
// For a phase in an Aligned subscription, there's a single aligned BillingPeriod for all items in that phase.
155173
// The period starts with the phase and iterates every BillingCadence duration, but can be reanchored to the time of an edit.
156174
func (s *SubscriptionSpec) GetAlignedBillingPeriodAt(phaseKey string, at time.Time) (timeutil.Period, error) {
@@ -370,6 +388,18 @@ func (s SubscriptionPhaseSpec) GetBillableItemsByKey() map[string][]*Subscriptio
370388
return res
371389
}
372390

391+
func (s SubscriptionPhaseSpec) HasEntitlements() bool {
392+
return lo.SomeBy(lo.Flatten(lo.Values(s.ItemsByKey)), func(item *SubscriptionItemSpec) bool {
393+
return item.RateCard.EntitlementTemplate != nil
394+
})
395+
}
396+
397+
func (s SubscriptionPhaseSpec) HasMeteredBillables() bool {
398+
return lo.SomeBy(lo.Flatten(lo.Values(s.ItemsByKey)), func(item *SubscriptionItemSpec) bool {
399+
return item.RateCard.Price != nil && item.RateCard.Price.Type() != productcatalog.FlatPriceType
400+
})
401+
}
402+
373403
func (s SubscriptionPhaseSpec) HasBillables() bool {
374404
return len(s.GetBillableItemsByKey()) > 0
375405
}

openmeter/subscription/validators/customer/validator.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,68 @@ import (
1313

1414
var _ customer.RequestValidator = (*Validator)(nil)
1515

16-
func NewValidator(subscriptionService subscription.Service) (*Validator, error) {
16+
func NewValidator(subscriptionService subscription.Service, customerService customer.Service) (*Validator, error) {
1717
if subscriptionService == nil {
1818
return nil, fmt.Errorf("subscription service is required")
1919
}
20+
if customerService == nil {
21+
return nil, fmt.Errorf("customer service is required")
22+
}
2023

2124
return &Validator{
2225
subscriptionService: subscriptionService,
26+
customerService: customerService,
2327
}, nil
2428
}
2529

2630
type Validator struct {
2731
customer.NoopRequestValidator
2832
subscriptionService subscription.Service
33+
customerService customer.Service
34+
}
35+
36+
func (v *Validator) ValidateUpdateCustomer(ctx context.Context, input customer.UpdateCustomerInput) error {
37+
if err := input.Validate(); err != nil {
38+
return err
39+
}
40+
41+
// The subject association can only be changed if the customer doesn't have a subscription
42+
subscriptions, err := v.subscriptionService.List(ctx, subscription.ListSubscriptionsInput{
43+
Namespaces: []string{input.CustomerID.Namespace},
44+
Customers: []string{input.CustomerID.ID},
45+
ActiveAt: lo.ToPtr(clock.Now()),
46+
})
47+
if err != nil {
48+
return err
49+
}
50+
51+
hasSub := len(subscriptions.Items) > 0
52+
53+
// If there's an update to the subject keys we need additional checks
54+
if input.CustomerMutate.UsageAttribution.SubjectKeys != nil {
55+
currentCustomer, err := v.customerService.GetCustomer(ctx, customer.GetCustomerInput{
56+
Namespace: input.CustomerID.Namespace,
57+
ID: input.CustomerID.ID,
58+
})
59+
if err != nil {
60+
return err
61+
}
62+
63+
// Let's check the two subjectKey arrays are the same
64+
if hasSub {
65+
if len(currentCustomer.UsageAttribution.SubjectKeys) != len(input.CustomerMutate.UsageAttribution.SubjectKeys) {
66+
return fmt.Errorf("cannot change subject keys for customer with active subscriptions")
67+
}
68+
69+
for i, key := range currentCustomer.UsageAttribution.SubjectKeys {
70+
if key != input.CustomerMutate.UsageAttribution.SubjectKeys[i] {
71+
return fmt.Errorf("cannot change subject keys for customer with active subscriptions")
72+
}
73+
}
74+
}
75+
}
76+
77+
return nil
2978
}
3079

3180
func (v *Validator) ValidateDeleteCustomer(ctx context.Context, input customer.DeleteCustomerInput) error {

0 commit comments

Comments
 (0)