| package service |
|
|
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "time" |
|
|
| "github.com/samber/lo" |
| "go.opentelemetry.io/otel/attribute" |
| "go.opentelemetry.io/otel/trace" |
|
|
| "github.com/openmeterio/openmeter/openmeter/billing" |
| "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync" |
| "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/reconciler" |
| "github.com/openmeterio/openmeter/openmeter/customer" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| "github.com/openmeterio/openmeter/pkg/clock" |
| "github.com/openmeterio/openmeter/pkg/convert" |
| "github.com/openmeterio/openmeter/pkg/currencyx" |
| "github.com/openmeterio/openmeter/pkg/filter" |
| "github.com/openmeterio/openmeter/pkg/framework/tracex" |
| "github.com/openmeterio/openmeter/pkg/models" |
| "github.com/openmeterio/openmeter/pkg/pagination" |
| ) |
|
|
| const ( |
| SubscriptionSyncComponentName billing.ComponentName = "subscription-sync" |
| ) |
|
|
| func (s *Service) invoicePendingLines(ctx context.Context, customer customer.CustomerID) error { |
| span := tracex.StartWithNoValue(ctx, s.tracer, "billing.worker.subscription.sync.invoicePendingLines", trace.WithAttributes( |
| attribute.String("customer_id", customer.ID), |
| )) |
|
|
| return span.Wrap(func(ctx context.Context) error { |
| _, err := s.billingService.InvoicePendingLines( |
| ctx, |
| billing.InvoicePendingLinesInput{ |
| Customer: customer, |
| ForceAsyncAdvance: s.forceAsyncInvoicePendingLines, |
| }, |
| billing.WithPartialInvoiceLinesDisabled(), |
| billing.WithMaxLinesPerInvoice(s.featureFlags.MaxLinesPerCollectedInvoice), |
| ) |
| if err != nil { |
| if errors.Is(err, billing.ErrInvoiceCreateNoLines) { |
| return nil |
| } |
|
|
| return err |
| } |
|
|
| return nil |
| }) |
| } |
|
|
| func (s *Service) HandleSubscriptionSyncEvent(ctx context.Context, event *subscription.SubscriptionSyncEvent) error { |
| if event == nil { |
| return nil |
| } |
|
|
| return s.synchronizeSubscriptionAndInvoiceCustomer(ctx, newSubscriptionReferenceOrView(event.Subscription), time.Now()) |
| } |
|
|
| func (s *Service) synchronizeSubscriptionAndInvoiceCustomer(ctx context.Context, refOrView subscriptionReferenceOrView, asOf time.Time) error { |
| res, err := s.synchronizeSubscription(ctx, refOrView, asOf) |
| if err != nil { |
| return fmt.Errorf("synchronize subscription: %w", err) |
| } |
|
|
| if res != nil && res.View != nil { |
| customerID := customer.CustomerID{ |
| Namespace: res.View.Subscription.Namespace, |
| ID: res.View.Subscription.CustomerId, |
| } |
| |
| if err := s.invoicePendingLines(ctx, customerID); err != nil { |
| return fmt.Errorf("invoice pending lines (post): %w [customer_id=%s]", err, customerID.ID) |
| } |
| } |
|
|
| return nil |
| } |
|
|
| type synchronizeSubscriptionResult struct { |
| View *subscription.SubscriptionView |
| Deleted bool |
| } |
|
|
| func (s *Service) synchronizeSubscription(ctx context.Context, refOrView subscriptionReferenceOrView, asOf time.Time, opts ...subscriptionsync.SynchronizeSubscriptionOption) (*synchronizeSubscriptionResult, error) { |
| subscriptionID := refOrView.GetID() |
|
|
| span := tracex.Start[*synchronizeSubscriptionResult](ctx, s.tracer, "billing.worker.subscription.sync.SynchronizeSubscription", trace.WithAttributes( |
| attribute.String("subscription_id", subscriptionID.ID), |
| attribute.String("as_of", asOf.Format(time.RFC3339)), |
| )) |
|
|
| options := subscriptionsync.SynchronizeSubscriptionOptions{} |
| for _, opt := range opts { |
| opt(&options) |
| } |
|
|
| return span.Wrap(func(ctx context.Context) (*synchronizeSubscriptionResult, error) { |
| subs, err := s.getSubscription(ctx, subscriptionID) |
| if err != nil { |
| return nil, err |
| } |
|
|
| var subsView *subscription.SubscriptionView |
| if subs.IsDeleted() { |
| subsView = nil |
| } else if refOrView.Type() == SubscriptionReferenceTypeView { |
| view, err := refOrView.AsSubscriptionView() |
| if err != nil { |
| return nil, err |
| } |
|
|
| subsView = &view |
| } else { |
| view, err := s.subscriptionService.GetView(ctx, subscriptionID) |
| if err != nil { |
| return nil, err |
| } |
|
|
| subsView = &view |
| } |
|
|
| res := &synchronizeSubscriptionResult{ |
| View: subsView, |
| Deleted: subs.IsDeleted(), |
| } |
|
|
| customerID := customer.CustomerID{ |
| Namespace: subs.Namespace, |
| ID: subs.CustomerId, |
| } |
|
|
| var customerDeletedAt *time.Time |
| var subscriptionEndProrationMode billing.SubscriptionEndProrationMode |
| if subsView != nil && subsView.Spec.HasBillables() { |
| |
| |
| customerOverride, err := s.billingService.GetCustomerOverride(ctx, billing.GetCustomerOverrideInput{ |
| Customer: customerID, |
| Expand: billing.CustomerOverrideExpand{ |
| Customer: true, |
| }, |
| }) |
| if err != nil { |
| return nil, fmt.Errorf("getting billing profile: %w", err) |
| } |
|
|
| if customerOverride.Customer != nil { |
| customerDeletedAt = convert.SafeToUTC(customerOverride.Customer.GetDeletedAt()) |
| } |
| subscriptionEndProrationMode = customerOverride.MergedProfile.WorkflowConfig.Invoicing.SubscriptionEndProrationMode |
|
|
| if customerOverride.Customer != nil && customerOverride.Customer.DeletedAt != nil && !customerOverride.Customer.DeletedAt.After(subsView.Spec.ActiveFrom) { |
| if options.DryRun { |
| return res, nil |
| } |
|
|
| if err := s.updateSyncState(ctx, updateSyncStateInput{ |
| SubscriptionID: subscriptionID, |
| |
| PreventFurtherSyncs: true, |
| }); err != nil { |
| return nil, fmt.Errorf("updating sync state: %w", err) |
| } |
|
|
| s.logger.WarnContext(ctx, "customer deleted before subscription start, skipping sync", "subscription_id", subscriptionID.ID, "customer_id", customerID.ID) |
| return res, nil |
| } |
| } |
|
|
| cur, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). |
| WithCode(subs.Currency). |
| Build() |
| if err != nil { |
| return nil, fmt.Errorf("getting currency calculator: %w", err) |
| } |
|
|
| return withBillingLock(ctx, s, customer.CustomerID{ |
| Namespace: subs.Namespace, |
| ID: subs.CustomerId, |
| }, func(ctx context.Context) (*synchronizeSubscriptionResult, error) { |
| |
| linesDiff, err := s.buildSyncPlan(ctx, buildSyncPlanInput{ |
| Subscription: subs, |
| SubscriptionView: subsView, |
| AsOf: asOf, |
| CustomerDeletedAt: customerDeletedAt, |
| SubscriptionEndProrationMode: subscriptionEndProrationMode, |
| Currency: cur, |
| DryRun: options.DryRun, |
| }) |
| if err != nil { |
| return nil, err |
| } |
|
|
| |
| |
| hasBillables := false |
| if subsView != nil { |
| hasBillables = subsView.Spec.HasBillables() |
| } |
|
|
| if linesDiff == nil || linesDiff.IsEmpty() { |
| if options.DryRun { |
| return res, nil |
| } |
|
|
| generationLimit := time.Time{} |
| if linesDiff != nil { |
| generationLimit = linesDiff.SubscriptionMaxGenerationTimeLimit |
| } |
|
|
| if err := s.updateSyncState(ctx, updateSyncStateInput{ |
| SubscriptionID: subscriptionID, |
| MaxGenerationTimeLimit: generationLimit, |
| HasBillables: hasBillables, |
| }); err != nil { |
| return nil, fmt.Errorf("updating sync state: %w", err) |
| } |
|
|
| return res, nil |
| } |
|
|
| if err := s.reconciler.Apply(ctx, reconciler.ApplyInput{ |
| DryRun: options.DryRun, |
| Customer: customerID, |
| Currency: cur, |
| Plan: linesDiff, |
| }); err != nil { |
| return nil, err |
| } |
|
|
| if options.DryRun { |
| return res, nil |
| } |
|
|
| if err := s.updateSyncState(ctx, updateSyncStateInput{ |
| SubscriptionID: subscriptionID, |
| MaxGenerationTimeLimit: linesDiff.SubscriptionMaxGenerationTimeLimit, |
| HasBillables: hasBillables, |
| }); err != nil { |
| return nil, fmt.Errorf("updating sync state: %w", err) |
| } |
|
|
| return res, nil |
| }) |
| }) |
| } |
|
|
| func withBillingLock[T any](ctx context.Context, s *Service, customerID customer.CustomerID, fn func(ctx context.Context) (T, error)) (T, error) { |
| var out T |
| err := s.billingService.WithLock(ctx, customerID, func(ctx context.Context) error { |
| var err error |
| out, err = fn(ctx) |
| return err |
| }) |
| if err != nil { |
| return lo.Empty[T](), err |
| } |
|
|
| return out, nil |
| } |
|
|
| func (s *Service) getSubscription(ctx context.Context, subscriptionID models.NamespacedID) (subscription.Subscription, error) { |
| subs, err := s.subscriptionService.List(ctx, subscription.ListSubscriptionsInput{ |
| Namespaces: []string{subscriptionID.Namespace}, |
| ID: &filter.FilterULID{FilterString: filter.FilterString{In: &[]string{subscriptionID.ID}}}, |
| IncludeDeleted: true, |
| Page: pagination.Page{ |
| PageNumber: 1, |
| PageSize: 1, |
| }, |
| }) |
| if err != nil { |
| return subscription.Subscription{}, fmt.Errorf("getting subscription: %w", err) |
| } |
|
|
| if len(subs.Items) == 0 { |
| return subscription.Subscription{}, subscription.NewSubscriptionNotFoundError(subscriptionID.ID) |
| } |
|
|
| return subs.Items[0], nil |
| } |
|
|
| type updateSyncStateInput struct { |
| SubscriptionID models.NamespacedID |
| MaxGenerationTimeLimit time.Time |
| PreventFurtherSyncs bool |
| HasBillables bool |
| } |
|
|
| func (s *Service) updateSyncState(ctx context.Context, in updateSyncStateInput) error { |
| span := tracex.StartWithNoValue(ctx, s.tracer, "billing.worker.subscription.sync.updateSyncState", trace.WithAttributes( |
| attribute.String("subscription_id", in.SubscriptionID.ID), |
| attribute.String("max_generation_time_limit", in.MaxGenerationTimeLimit.Format(time.RFC3339)), |
| )) |
|
|
| return span.Wrap(func(ctx context.Context) error { |
| hasBillables := in.HasBillables |
| if in.PreventFurtherSyncs { |
| |
| |
| hasBillables = false |
| } |
|
|
| if !hasBillables { |
| return s.subscriptionSyncAdapter.UpsertSyncState(ctx, subscriptionsync.UpsertSyncStateInput{ |
| SubscriptionID: in.SubscriptionID, |
| HasBillables: false, |
| SyncedAt: clock.Now().UTC(), |
| }) |
| } |
|
|
| nextSyncAfter := in.MaxGenerationTimeLimit |
|
|
| if in.MaxGenerationTimeLimit.IsZero() { |
| |
| if nextSyncAfter.IsZero() { |
| s.logger.WarnContext(ctx, "cannot determine the next sync after, syncing immediately", "subscription_id", in.SubscriptionID.ID) |
| nextSyncAfter = clock.Now().UTC() |
| } |
| } |
|
|
| return s.subscriptionSyncAdapter.UpsertSyncState(ctx, subscriptionsync.UpsertSyncStateInput{ |
| SubscriptionID: in.SubscriptionID, |
| HasBillables: true, |
| NextSyncAfter: lo.ToPtr(nextSyncAfter), |
| SyncedAt: clock.Now().UTC(), |
| }) |
| }) |
| } |
|
|