| package subscription |
|
|
| import ( |
| "fmt" |
| "log/slog" |
| "runtime/debug" |
| "time" |
|
|
| "github.com/openmeterio/openmeter/pkg/clock" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| |
| type Timing struct { |
| Custom *time.Time |
| Enum *TimingEnum |
| } |
|
|
| func (c Timing) Validate() error { |
| if c.Custom == nil && c.Enum == nil { |
| return fmt.Errorf("change timing must have either a custom time or an enum") |
| } |
|
|
| if c.Custom != nil && c.Enum != nil { |
| return fmt.Errorf("change timing cannot have both a custom time and an enum") |
| } |
|
|
| if c.Enum != nil { |
| return c.Enum.Validate() |
| } |
|
|
| return nil |
| } |
|
|
| func (c Timing) Resolve() (time.Time, error) { |
| var def time.Time |
|
|
| if err := c.Validate(); err != nil { |
| return def, err |
| } |
|
|
| if c.Custom != nil { |
| return *c.Custom, nil |
| } |
|
|
| if c.Enum != nil { |
| switch *c.Enum { |
| case TimingImmediate: |
| return clock.Now(), nil |
| default: |
| return def, models.NewGenericValidationError(fmt.Errorf("unsupported enum value: %s", *c.Enum)) |
| } |
| } |
|
|
| return def, fmt.Errorf("no logical branch entered") |
| } |
|
|
| func (c Timing) ResolveForSpec(spec SubscriptionSpec) (time.Time, error) { |
| var def time.Time |
|
|
| if err := c.Validate(); err != nil { |
| return def, err |
| } |
|
|
| if c.Custom != nil { |
| return *c.Custom, nil |
| } |
|
|
| if c.Enum != nil { |
| switch *c.Enum { |
| case TimingImmediate: |
| return clock.Now(), nil |
| case TimingNextBillingCycle: |
| if spec.BillingCadence.IsZero() { |
| return def, models.NewGenericValidationError(fmt.Errorf("subscription does not have a billing cadence, there isn't a next_billing_cycle")) |
| } |
|
|
| period, err := spec.GetAlignedBillingPeriodAt(clock.Now()) |
|
|
| return period.To, err |
| default: |
| return def, models.NewGenericValidationError(fmt.Errorf("unsupported enum value: %s", *c.Enum)) |
| } |
| } |
|
|
| return def, fmt.Errorf("no logical branch entered") |
| } |
|
|
| func (c Timing) ValidateForAction(action SubscriptionAction, subView *SubscriptionView) error { |
| if err := c.Validate(); err != nil { |
| return err |
| } |
|
|
| switch action { |
| case SubscriptionAction("any"): |
| return nil |
| case SubscriptionActionUpdate: |
| if subView == nil { |
| return fmt.Errorf("missing subscription view") |
| } |
|
|
| if c.Custom != nil { |
| return models.NewGenericValidationError(fmt.Errorf("cannot edit running subscription with custom timing")) |
| } |
|
|
| currentTime := clock.Now() |
| editTime, err := c.ResolveForSpec(subView.Spec) |
| if err != nil { |
| return fmt.Errorf("failed to resolve timing: %w", err) |
| } |
|
|
| |
| if editTime.Before(currentTime) { |
| return models.NewGenericValidationError(fmt.Errorf("cannot execute edits in the past")) |
| } |
|
|
| currentPhase, currentPhaseExists := subView.Spec.GetCurrentPhaseAt(currentTime) |
| editPhase, editPhaseExists := subView.Spec.GetCurrentPhaseAt(editTime) |
|
|
| if currentPhaseExists && editPhaseExists && currentPhase.PhaseKey != editPhase.PhaseKey { |
| |
| if c.Enum != nil && *c.Enum == TimingNextBillingCycle { |
| return models.NewGenericValidationError(fmt.Errorf("cannot edit to the next billing cycle as it falls into a different phase")) |
| } |
|
|
| |
| return models.NewGenericValidationError(fmt.Errorf("cannot time-travel to edit a different phase")) |
| } |
|
|
| case SubscriptionActionCreate: |
| if c.Enum != nil && *c.Enum == TimingImmediate { |
| return nil |
| } |
|
|
| tolerance := 2 * time.Minute |
|
|
| if c.Custom != nil { |
| if c.Custom.Before(clock.Now().Add(-tolerance)) { |
| return models.NewGenericValidationError(fmt.Errorf("cannot create subscription in the past")) |
| } |
|
|
| return nil |
| } |
|
|
| case SubscriptionActionCancel: |
| if subView == nil { |
| return fmt.Errorf("missing subscription view") |
| } |
|
|
| if c.Custom != nil { |
| if !c.isDateAlignedWithBillingCadence(subView.Spec, *c.Custom) { |
| return models.NewGenericValidationError(fmt.Errorf("cannot cancel aligned subscription with custom misaligned timing")) |
| } |
| } |
|
|
| case SubscriptionActionChangeAddons: |
| if subView == nil { |
| return fmt.Errorf("missing subscription view") |
| } |
|
|
| |
| return nil |
| default: |
| slog.Warn("timing called with unsupported action", slog.Any("action", action), slog.String("stack", string(debug.Stack()))) |
|
|
| return nil |
| } |
|
|
| return nil |
| } |
|
|
| func (c Timing) isDateAlignedWithBillingCadence(spec SubscriptionSpec, date time.Time) bool { |
| period, err := spec.GetAlignedBillingPeriodAt(date) |
| if err != nil { |
| return false |
| } |
|
|
| switch { |
| case period.From.Equal(date): |
| return true |
| case period.To.Equal(date): |
| return true |
| default: |
| return false |
| } |
| } |
|
|
| type TimingEnum string |
|
|
| const ( |
| |
| TimingImmediate TimingEnum = "immediate" |
| |
| |
| TimingNextBillingCycle TimingEnum = "next_billing_cycle" |
| ) |
|
|
| func (c TimingEnum) Validate() error { |
| switch c { |
| case TimingImmediate, TimingNextBillingCycle: |
| return nil |
| default: |
| return fmt.Errorf("invalid change timing: %s", c) |
| } |
| } |
|
|