diff --git a/app/common/billing.go b/app/common/billing.go index 62289ca84f..7ca3afff9a 100644 --- a/app/common/billing.go +++ b/app/common/billing.go @@ -199,7 +199,7 @@ func NewBillingRegistry( if err != nil { return BillingRegistry{}, err } - subscriptionSyncService, err := NewBillingSubscriptionSyncService(logger, subscriptionServices, billingRegistry, subscriptionSyncAdapter, tracer) + subscriptionSyncService, err := NewBillingSubscriptionSyncService(logger, subscriptionServices, billingRegistry, subscriptionSyncAdapter, tracer, creditsConfig) if err != nil { return BillingRegistry{}, err } @@ -276,13 +276,16 @@ func NewBillingSubscriptionSyncAdapter(db *entdb.Client) (subscriptionsync.Adapt }) } -func NewBillingSubscriptionSyncService(logger *slog.Logger, subsServices SubscriptionServiceWithWorkflow, billingRegistry BillingRegistry, subscriptionSyncAdapter subscriptionsync.Adapter, tracer trace.Tracer) (subscriptionsync.Service, error) { +func NewBillingSubscriptionSyncService(logger *slog.Logger, subsServices SubscriptionServiceWithWorkflow, billingRegistry BillingRegistry, subscriptionSyncAdapter subscriptionsync.Adapter, tracer trace.Tracer, creditsConfig config.CreditsConfiguration) (subscriptionsync.Service, error) { return subscriptionsyncservice.New(subscriptionsyncservice.Config{ SubscriptionService: subsServices.Service, BillingService: billingRegistry.Billing, ChargesService: billingRegistry.ChargesServiceOrNil(), SubscriptionSyncAdapter: subscriptionSyncAdapter, - Logger: logger, - Tracer: tracer, + FeatureFlags: subscriptionsyncservice.FeatureFlags{ + EnableCreditThenInvoice: creditsConfig.EnableCreditThenInvoice, + }, + Logger: logger, + Tracer: tracer, }) } diff --git a/app/config/config_test.go b/app/config/config_test.go index e242f317fd..041fb32483 100644 --- a/app/config/config_test.go +++ b/app/config/config_test.go @@ -188,7 +188,8 @@ func TestComplete(t *testing.T) { }, }, Credits: CreditsConfiguration{ - Enabled: false, + Enabled: false, + EnableCreditThenInvoice: false, }, Sink: SinkConfiguration{ GroupId: "openmeter-sink-worker", diff --git a/app/config/credits.go b/app/config/credits.go index a1a20293ee..0c1b5d623f 100644 --- a/app/config/credits.go +++ b/app/config/credits.go @@ -7,7 +7,8 @@ import ( ) type CreditsConfiguration struct { - Enabled bool `yaml:"enabled"` + Enabled bool `yaml:"enabled"` + EnableCreditThenInvoice bool `yaml:"enable_credit_then_invoice"` } func (c CreditsConfiguration) Validate() error { @@ -20,4 +21,5 @@ func (c CreditsConfiguration) Validate() error { func ConfigureCredits(v *viper.Viper) { v.SetDefault("credits.enabled", false) + v.SetDefault("credits.enable_credit_then_invoice", false) } diff --git a/cmd/billing-worker/wire_gen.go b/cmd/billing-worker/wire_gen.go index 564b69cb14..62854c9b76 100644 --- a/cmd/billing-worker/wire_gen.go +++ b/cmd/billing-worker/wire_gen.go @@ -336,7 +336,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - subscriptionsyncService, err := common.NewBillingSubscriptionSyncService(logger, subscriptionServiceWithWorkflow, billingRegistry, subscriptionsyncAdapter, tracer) + subscriptionsyncService, err := common.NewBillingSubscriptionSyncService(logger, subscriptionServiceWithWorkflow, billingRegistry, subscriptionsyncAdapter, tracer, creditsConfiguration) if err != nil { cleanup7() cleanup6() diff --git a/cmd/jobs/internal/wire_gen.go b/cmd/jobs/internal/wire_gen.go index 782654bceb..7909cb1023 100644 --- a/cmd/jobs/internal/wire_gen.go +++ b/cmd/jobs/internal/wire_gen.go @@ -411,7 +411,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - subscriptionsyncService, err := common.NewBillingSubscriptionSyncService(logger, subscriptionServiceWithWorkflow, billingRegistry, subscriptionsyncAdapter, tracer) + subscriptionsyncService, err := common.NewBillingSubscriptionSyncService(logger, subscriptionServiceWithWorkflow, billingRegistry, subscriptionsyncAdapter, tracer, creditsConfiguration) if err != nil { cleanup7() cleanup6() diff --git a/config.example.yaml b/config.example.yaml index 6679215e65..976e6c34cb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -76,6 +76,7 @@ billing: credits: enabled: true + enable_credit_then_invoice: false apps: baseURL: https://example.com diff --git a/e2e/config.yaml b/e2e/config.yaml index a9369cb9fb..de60ac7de3 100644 --- a/e2e/config.yaml +++ b/e2e/config.yaml @@ -45,6 +45,7 @@ postgres: # to verify that credit_only settlement mode is rejected when credits are disabled. credits: enabled: false + enable_credit_then_invoice: false meters: - slug: ingest diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/patch.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/patch.go index 5dd818df77..6231d5652c 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/patch.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patch.go @@ -60,23 +60,44 @@ type patchCollectionRouter struct { hierarchyCollection *lineHierarchyPatchCollection flatFeeChargeCollection *flatFeeChargeCollection usageBasedChargeCollection *usageBasedChargeCollection + creditThenInvoiceEnabled bool + creditsEnabled bool } -func newPatchCollectionRouter(capacity int, invoices persistedstate.Invoices) (*patchCollectionRouter, error) { - if invoices == nil { - return nil, fmt.Errorf("invoices is required") +type patchCollectionRouterConfig struct { + capacity int + invoices persistedstate.Invoices + creditThenInvoiceEnabled bool + creditsEnabled bool +} + +func (c patchCollectionRouterConfig) Validate() error { + if c.capacity <= 0 { + return fmt.Errorf("capacity is required") + } + if c.invoices == nil { + return fmt.Errorf("invoices is required") } + return nil +} - lineCollection, err := newLineInvoicePatchCollection(invoices, capacity) +func newPatchCollectionRouter(cfg patchCollectionRouterConfig) (*patchCollectionRouter, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + lineCollection, err := newLineInvoicePatchCollection(cfg.invoices, cfg.capacity) if err != nil { return nil, fmt.Errorf("creating line collection: %w", err) } return &patchCollectionRouter{ lineCollection: lineCollection, - hierarchyCollection: newLineHierarchyPatchCollection(capacity), - flatFeeChargeCollection: newFlatFeeChargeCollection(capacity), - usageBasedChargeCollection: newUsageBasedChargeCollection(capacity), + hierarchyCollection: newLineHierarchyPatchCollection(cfg.capacity), + flatFeeChargeCollection: newFlatFeeChargeCollection(cfg.capacity), + usageBasedChargeCollection: newUsageBasedChargeCollection(cfg.capacity), + creditThenInvoiceEnabled: cfg.creditThenInvoiceEnabled, + creditsEnabled: cfg.creditsEnabled, }, nil } @@ -96,7 +117,12 @@ func (c patchCollectionRouter) GetCollectionFor(item persistedstate.Item) (Patch } func (c patchCollectionRouter) ResolveDefaultCollection(target targetstate.StateItem) (PatchCollection, error) { - if target.Subscription.SettlementMode != productcatalog.CreditOnlySettlementMode { + if !c.creditsEnabled { + return c.lineCollection, nil + } + + // If credit then invoice is not enabled, we return the lineCollection which is generally an invoice_only settlement mode implementation. + if target.Subscription.SettlementMode == productcatalog.CreditThenInvoiceSettlementMode && !c.creditThenInvoiceEnabled { return c.lineCollection, nil } diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/patch_test.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/patch_test.go new file mode 100644 index 0000000000..a8febd0631 --- /dev/null +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patch_test.go @@ -0,0 +1,127 @@ +package reconciler + +import ( + "testing" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/persistedstate" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/targetstate" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/subscription" +) + +func TestPatchCollectionRouterResolveDefaultCollection(t *testing.T) { + t.Parallel() + + flatRateCard := &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "flat", + Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromInt(100), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }), + }, + } + usageRateCard := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "usage", + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromInt(1)}), + }, + } + + testCases := []struct { + name string + settlementMode productcatalog.SettlementMode + enableCreditThenInvoice bool + enableCredits bool + rateCard productcatalog.RateCard + expectedCollection any + }{ + { + name: "invoice only stays on invoice lines", + settlementMode: productcatalog.InvoiceOnlySettlementMode, + rateCard: flatRateCard, + expectedCollection: &lineInvoicePatchCollection{}, + }, + { + name: "credit only flat fee uses flat fee charges", + settlementMode: productcatalog.CreditOnlySettlementMode, + rateCard: flatRateCard, + expectedCollection: &flatFeeChargeCollection{}, + enableCredits: true, + }, + { + name: "credit only usage uses usage based charges", + settlementMode: productcatalog.CreditOnlySettlementMode, + rateCard: usageRateCard, + expectedCollection: &usageBasedChargeCollection{}, + enableCredits: true, + }, + { + name: "credit then invoice disabled stays on invoice lines", + settlementMode: productcatalog.CreditThenInvoiceSettlementMode, + enableCredits: true, + enableCreditThenInvoice: false, + rateCard: flatRateCard, + expectedCollection: &lineInvoicePatchCollection{}, + }, + { + name: "credit then invoice enabled flat fee uses flat fee charges", + settlementMode: productcatalog.CreditThenInvoiceSettlementMode, + enableCredits: true, + enableCreditThenInvoice: true, + rateCard: flatRateCard, + expectedCollection: &flatFeeChargeCollection{}, + }, + { + name: "credit then invoice enabled usage uses usage based charges", + settlementMode: productcatalog.CreditThenInvoiceSettlementMode, + enableCredits: true, + enableCreditThenInvoice: true, + rateCard: usageRateCard, + expectedCollection: &usageBasedChargeCollection{}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + router, err := newPatchCollectionRouter(patchCollectionRouterConfig{ + capacity: 1, + invoices: persistedstate.Invoices{}, + creditThenInvoiceEnabled: tt.enableCreditThenInvoice, + creditsEnabled: tt.enableCredits, + }) + require.NoError(t, err) + + collection, err := router.ResolveDefaultCollection(testTargetStateItem(tt.settlementMode, tt.rateCard)) + require.NoError(t, err) + require.IsType(t, tt.expectedCollection, collection) + }) + } +} + +func testTargetStateItem(settlementMode productcatalog.SettlementMode, rateCard productcatalog.RateCard) targetstate.StateItem { + return targetstate.StateItem{ + SubscriptionItemWithPeriods: targetstate.SubscriptionItemWithPeriods{ + UniqueID: "item-1", + SubscriptionItemView: subscription.SubscriptionItemView{ + Spec: subscription.SubscriptionItemSpec{ + CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ + CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ + PhaseKey: "phase-1", + ItemKey: "item-1", + RateCard: rateCard, + }, + }, + }, + }, + }, + Subscription: subscription.Subscription{ + SettlementMode: settlementMode, + }, + } +} diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go index 91a5d30dcf..e4f2a8b5a9 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go @@ -28,25 +28,33 @@ type Reconciler interface { } type Config struct { - BillingService billing.Service - ChargesService charges.Service - Logger *slog.Logger + BillingService billing.Service + ChargesService charges.Service + EnableCreditThenInvoice bool + Logger *slog.Logger } func (c Config) Validate() error { if c.BillingService == nil { return fmt.Errorf("billing service is required") } + if c.Logger == nil { return fmt.Errorf("logger is required") } + + if c.EnableCreditThenInvoice && c.ChargesService == nil { + return fmt.Errorf("charges service is required when credit then invoice is enabled") + } + return nil } type Service struct { - billingService billing.Service - chargesService charges.Service - logger *slog.Logger + billingService billing.Service + chargesService charges.Service + logger *slog.Logger + enableCreditThenInvoice bool invoiceUpdater *invoiceupdater.Updater } @@ -57,10 +65,11 @@ func New(config Config) (*Service, error) { } return &Service{ - billingService: config.BillingService, - logger: config.Logger, - invoiceUpdater: invoiceupdater.New(config.BillingService, config.Logger), - chargesService: config.ChargesService, + billingService: config.BillingService, + logger: config.Logger, + invoiceUpdater: invoiceupdater.New(config.BillingService, config.Logger), + chargesService: config.ChargesService, + enableCreditThenInvoice: config.EnableCreditThenInvoice && config.ChargesService != nil, }, nil } @@ -205,7 +214,13 @@ func (s *Service) Plan(ctx context.Context, input PlanInput) (*Plan, error) { return nil, fmt.Errorf("credit only settlement mode is not supported without charges service enabled") } - patchCollections, err := newPatchCollectionRouter(len(input.Target.Items)+len(input.Persisted.ByUniqueID), input.Persisted.Invoices) + patchCollections, err := newPatchCollectionRouter( + patchCollectionRouterConfig{ + capacity: len(input.Target.Items) + len(input.Persisted.ByUniqueID), + invoices: input.Persisted.Invoices, + creditThenInvoiceEnabled: s.enableCreditThenInvoice, + creditsEnabled: s.chargesService != nil, + }) if err != nil { return nil, fmt.Errorf("creating collection by type: %w", err) } diff --git a/openmeter/billing/worker/subscriptionsync/service/service.go b/openmeter/billing/worker/subscriptionsync/service/service.go index 5e1bb8e032..9105394db0 100644 --- a/openmeter/billing/worker/subscriptionsync/service/service.go +++ b/openmeter/billing/worker/subscriptionsync/service/service.go @@ -18,6 +18,7 @@ import ( type FeatureFlags struct { EnableFlatFeeInAdvanceProrating bool EnableFlatFeeInArrearsProrating bool + EnableCreditThenInvoice bool } type Config struct { @@ -73,9 +74,10 @@ func New(config Config) (*Service, error) { return nil, err } reconcilerSvc, err := reconciler.New(reconciler.Config{ - BillingService: config.BillingService, - ChargesService: config.ChargesService, - Logger: config.Logger, + BillingService: config.BillingService, + ChargesService: config.ChargesService, + EnableCreditThenInvoice: config.FeatureFlags.EnableCreditThenInvoice, + Logger: config.Logger, }) if err != nil { return nil, err