From 7a906df8160d914d400a1ed81860de2a9d5e7d98 Mon Sep 17 00:00:00 2001 From: Robert Borbely Date: Tue, 23 Jun 2026 11:19:52 +0200 Subject: [PATCH 1/3] feat(taxcode): prevent deletion of plan-referenced tax codes and validate tax codes on plan publish --- app/common/productcatalog.go | 6 + cmd/billing-worker/wire_gen.go | 14 +- cmd/jobs/internal/wire_gen.go | 14 +- cmd/server/wire_gen.go | 14 +- openmeter/productcatalog/errors.go | 10 ++ openmeter/productcatalog/plan.go | 21 +++ openmeter/productcatalog/plan/service/plan.go | 10 ++ .../productcatalog/plan/service/service.go | 7 + openmeter/productcatalog/ratecard.go | 38 +++++ openmeter/productcatalog/taxcoderesolver.go | 19 +++ .../taxcoderesolver/resolver.go | 56 +++++++ .../productcatalog/taxcoderesolver_test.go | 139 ++++++++++++++++++ openmeter/productcatalog/testutils/env.go | 5 + openmeter/subscription/testutils/service.go | 5 + openmeter/taxcode/service/hooks/planhook.go | 1 + test/billing/subscription_suite.go | 5 + test/customer/testenv.go | 5 + 17 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 openmeter/productcatalog/taxcoderesolver.go create mode 100644 openmeter/productcatalog/taxcoderesolver/resolver.go create mode 100644 openmeter/productcatalog/taxcoderesolver_test.go diff --git a/app/common/productcatalog.go b/app/common/productcatalog.go index abf6ac1ef7..c69e5fd676 100644 --- a/app/common/productcatalog.go +++ b/app/common/productcatalog.go @@ -25,6 +25,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" planaddonadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter" planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/openmeter/taxcode" "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" @@ -49,6 +50,7 @@ var Cost = wire.NewSet( var Plan = wire.NewSet( NewPlanService, + NewTaxCodeResolver, ) var Addon = wire.NewSet( @@ -71,6 +73,8 @@ func NewFeatureConnector( var NewFeatureResolver = featureresolver.New +var NewTaxCodeResolver = taxcoderesolver.New + func NewCostService( featureConnector feature.FeatureConnector, meterService meter.Service, @@ -88,6 +92,7 @@ func NewPlanService( logger *slog.Logger, db *entdb.Client, featureResolver productcatalog.FeatureResolver, + taxCodeResolver productcatalog.TaxCodeResolver, taxCodeService taxcode.Service, publisher eventbus.Publisher, ) (plan.Service, error) { @@ -102,6 +107,7 @@ func NewPlanService( return planservice.New(planservice.Config{ Adapter: adapter, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, Logger: logger.With("subsystem", "productcatalog.plan"), Publisher: publisher, diff --git a/cmd/billing-worker/wire_gen.go b/cmd/billing-worker/wire_gen.go index eb8a34dc85..6ff109e8bb 100644 --- a/cmd/billing-worker/wire_gen.go +++ b/cmd/billing-worker/wire_gen.go @@ -14,6 +14,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/namespace" "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" "github.com/openmeterio/openmeter/openmeter/watermill/router" @@ -279,7 +280,18 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - planService, err := common.NewPlanService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + taxCodeResolver, err := taxcoderesolver.New(taxcodeService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planService, err := common.NewPlanService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup7() cleanup6() diff --git a/cmd/jobs/internal/wire_gen.go b/cmd/jobs/internal/wire_gen.go index 96e178bc04..c78b9f0c99 100644 --- a/cmd/jobs/internal/wire_gen.go +++ b/cmd/jobs/internal/wire_gen.go @@ -26,6 +26,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" "github.com/openmeterio/openmeter/openmeter/registry" "github.com/openmeterio/openmeter/openmeter/secret" "github.com/openmeterio/openmeter/openmeter/streaming" @@ -288,7 +289,18 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - planService, err := common.NewPlanService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + taxCodeResolver, err := taxcoderesolver.New(taxcodeService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planService, err := common.NewPlanService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup7() cleanup6() diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index dcf4677858..c435cdd0cb 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -32,6 +32,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" "github.com/openmeterio/openmeter/openmeter/progressmanager" "github.com/openmeterio/openmeter/openmeter/registry" "github.com/openmeterio/openmeter/openmeter/secret" @@ -294,7 +295,18 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - planService, err := common.NewPlanService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + taxCodeResolver, err := taxcoderesolver.New(taxcodeService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planService, err := common.NewPlanService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup7() cleanup6() diff --git a/openmeter/productcatalog/errors.go b/openmeter/productcatalog/errors.go index 00cd96a2e3..f3b3d5e06d 100644 --- a/openmeter/productcatalog/errors.go +++ b/openmeter/productcatalog/errors.go @@ -121,6 +121,16 @@ var ErrRateCardFeatureArchived = models.NewValidationIssue( commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), ) +const ErrCodeRateCardTaxCodeNotFound models.ErrorCode = "rate_card_tax_code_not_found" + +var ErrRateCardTaxCodeNotFound = models.NewValidationIssue( + ErrCodeRateCardTaxCodeNotFound, + "tax code not found", + models.WithFieldString("taxConfig"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + const ErrCodeRateCardFeatureMismatch models.ErrorCode = "rate_card_feature_mismatch" var ErrRateCardFeatureMismatch = models.NewValidationIssue( diff --git a/openmeter/productcatalog/plan.go b/openmeter/productcatalog/plan.go index 2b19b299dc..69a232951b 100644 --- a/openmeter/productcatalog/plan.go +++ b/openmeter/productcatalog/plan.go @@ -365,3 +365,24 @@ func ValidatePlanWithFeatures(ctx context.Context, resolver NamespacedFeatureRes return errors.Join(errs...) } } + +func ValidatePlanWithTaxCodes(ctx context.Context, resolver NamespacedTaxCodeResolver) models.ValidatorFunc[Plan] { + return func(p Plan) error { + var errs []error + + for _, phase := range p.Phases { + phaseFieldSelector := models.NewFieldSelectorGroup( + models.NewFieldSelector("phases"). + WithExpression( + models.NewFieldAttrValue("key", phase.Key), + ), + ) + + if err := ValidateRateCardsWithTaxCodes(ctx, resolver)(phase.RateCards); err != nil { + errs = append(errs, models.ErrorWithFieldPrefix(phaseFieldSelector, err)) + } + } + + return errors.Join(errs...) + } +} diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index 5a1b484b72..990010dfa9 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -437,6 +437,16 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) ) } + // Validate plan with tax codes + err = pp.ValidateWith( + productcatalog.ValidatePlanWithTaxCodes(ctx, s.taxCodeResolver.WithNamespace(params.Namespace)), + ) + if err != nil { + errs = append(errs, fmt.Errorf("invalid plan [id=%s key=%s version=%d]: %w", + p.ID, p.Key, p.Version, err), + ) + } + // Check for incompatible add-ons assigned to this plan if p.Addons == nil { diff --git a/openmeter/productcatalog/plan/service/service.go b/openmeter/productcatalog/plan/service/service.go index b0c7512d95..fe0dd72b29 100644 --- a/openmeter/productcatalog/plan/service/service.go +++ b/openmeter/productcatalog/plan/service/service.go @@ -17,6 +17,7 @@ type Config struct { Publisher eventbus.Publisher FeatureResolver productcatalog.FeatureResolver + TaxCodeResolver productcatalog.TaxCodeResolver } func New(config Config) (plan.Service, error) { @@ -28,6 +29,10 @@ func New(config Config) (plan.Service, error) { return nil, errors.New("feature resolver is required") } + if config.TaxCodeResolver == nil { + return nil, errors.New("tax code resolver is required") + } + if config.Logger == nil { return nil, errors.New("logger is required") } @@ -47,6 +52,7 @@ func New(config Config) (plan.Service, error) { publisher: config.Publisher, featureResolver: config.FeatureResolver, + taxCodeResolver: config.TaxCodeResolver, }, nil } @@ -59,4 +65,5 @@ type service struct { publisher eventbus.Publisher featureResolver productcatalog.FeatureResolver + taxCodeResolver productcatalog.TaxCodeResolver } diff --git a/openmeter/productcatalog/ratecard.go b/openmeter/productcatalog/ratecard.go index ac87f773f5..069b7f8396 100644 --- a/openmeter/productcatalog/ratecard.go +++ b/openmeter/productcatalog/ratecard.go @@ -937,3 +937,41 @@ func ValidateRateCardsWithFeatures(ctx context.Context, resolver NamespacedFeatu return errors.Join(errs...) } } + +func ValidateRateCardsWithTaxCodes(ctx context.Context, resolver NamespacedTaxCodeResolver) func(cards RateCards) error { + return func(rateCards RateCards) error { + var errs []error + + for _, rateCard := range rateCards { + rc := rateCard.AsMeta() + + rateCardFieldSelector := models.NewFieldSelectorGroup( + models.NewFieldSelector("rateCards"). + WithExpression( + models.NewFieldAttrValue("key", rateCard.Key()), + ), + ) + + if rc.TaxConfig == nil || rc.TaxConfig.TaxCodeID == nil { + continue + } + + tc, err := resolver.ResolveTaxCode(ctx, *rc.TaxConfig.TaxCodeID) + if err != nil { + if models.IsGenericNotFoundError(err) || taxcode.IsTaxCodeNotFoundError(err) { + errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrRateCardTaxCodeNotFound)) + } else { + errs = append(errs, fmt.Errorf("failed to resolve tax code for ratecard: %w", err)) + } + + continue + } + + if tc == nil || tc.DeletedAt != nil { + errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrRateCardTaxCodeNotFound)) + } + } + + return errors.Join(errs...) + } +} diff --git a/openmeter/productcatalog/taxcoderesolver.go b/openmeter/productcatalog/taxcoderesolver.go new file mode 100644 index 0000000000..363c9f7ad0 --- /dev/null +++ b/openmeter/productcatalog/taxcoderesolver.go @@ -0,0 +1,19 @@ +package productcatalog + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/taxcode" +) + +// TaxCodeResolver resolves tax codes across namespaces and can create a namespace-scoped resolver. +type TaxCodeResolver interface { + ResolveTaxCode(ctx context.Context, namespace string, id string) (*taxcode.TaxCode, error) + WithNamespace(namespace string) NamespacedTaxCodeResolver +} + +// NamespacedTaxCodeResolver resolves tax codes within a fixed namespace. +type NamespacedTaxCodeResolver interface { + ResolveTaxCode(ctx context.Context, id string) (*taxcode.TaxCode, error) + Namespace() string +} diff --git a/openmeter/productcatalog/taxcoderesolver/resolver.go b/openmeter/productcatalog/taxcoderesolver/resolver.go new file mode 100644 index 0000000000..76e14475f0 --- /dev/null +++ b/openmeter/productcatalog/taxcoderesolver/resolver.go @@ -0,0 +1,56 @@ +package taxcoderesolver + +import ( + "context" + "errors" + + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/models" +) + +func New(service taxcode.Service) (productcatalog.TaxCodeResolver, error) { + if service == nil { + return nil, errors.New("tax code service is not set") + } + + return &resolver{service: service}, nil +} + +var _ productcatalog.NamespacedTaxCodeResolver = (*namespacedResolver)(nil) + +type namespacedResolver struct { + resolver *resolver + namespace string +} + +func (n *namespacedResolver) Namespace() string { return n.namespace } + +func (n *namespacedResolver) ResolveTaxCode(ctx context.Context, id string) (*taxcode.TaxCode, error) { + return n.resolver.ResolveTaxCode(ctx, n.namespace, id) +} + +var _ productcatalog.TaxCodeResolver = (*resolver)(nil) + +type resolver struct { + service taxcode.Service +} + +func (r *resolver) WithNamespace(namespace string) productcatalog.NamespacedTaxCodeResolver { + return &namespacedResolver{resolver: r, namespace: namespace} +} + +func (r *resolver) ResolveTaxCode(ctx context.Context, namespace string, id string) (*taxcode.TaxCode, error) { + if namespace == "" { + return nil, errors.New("namespace is not set") + } + + tc, err := r.service.GetTaxCode(ctx, taxcode.GetTaxCodeInput{ + NamespacedID: models.NamespacedID{Namespace: namespace, ID: id}, + }) + if err != nil { + return nil, err + } + + return &tc, nil +} diff --git a/openmeter/productcatalog/taxcoderesolver_test.go b/openmeter/productcatalog/taxcoderesolver_test.go new file mode 100644 index 0000000000..1821633534 --- /dev/null +++ b/openmeter/productcatalog/taxcoderesolver_test.go @@ -0,0 +1,139 @@ +package productcatalog + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/models" +) + +// stubTaxCodeResolver is a minimal NamespacedTaxCodeResolver for unit tests. +type stubTaxCodeResolver struct { + namespace string + result *taxcode.TaxCode + err error +} + +func (s stubTaxCodeResolver) Namespace() string { return s.namespace } + +func (s stubTaxCodeResolver) ResolveTaxCode(_ context.Context, _ string) (*taxcode.TaxCode, error) { + return s.result, s.err +} + +// makeFlatFeeRateCardWithTaxCodeID builds a minimal FlatFeeRateCard with a TaxConfig pointing at id. +func makeFlatFeeRateCardWithTaxCodeID(key, taxCodeID string) RateCard { + return &FlatFeeRateCard{ + RateCardMeta: RateCardMeta{ + Key: key, + Name: key, + TaxConfig: &TaxConfig{ + TaxCodeID: lo.ToPtr(taxCodeID), + }, + }, + } +} + +// makeFlatFeeRateCardNoTaxConfig builds a minimal FlatFeeRateCard without any TaxConfig. +func makeFlatFeeRateCardNoTaxConfig(key string) RateCard { + return &FlatFeeRateCard{ + RateCardMeta: RateCardMeta{ + Key: key, + Name: key, + }, + } +} + +// validTaxCode returns a non-deleted TaxCode stub. +func validTaxCode(id string) *taxcode.TaxCode { + now := time.Now() + return &taxcode.TaxCode{ + NamespacedID: models.NamespacedID{Namespace: "test", ID: id}, + ManagedModel: models.ManagedModel{ + CreatedAt: now, + UpdatedAt: now, + DeletedAt: nil, + }, + Key: "tc-key", + Name: "Tax Code", + } +} + +// deletedTaxCode returns a TaxCode stub whose DeletedAt is set. +func deletedTaxCode(id string) *taxcode.TaxCode { + now := time.Now() + deleted := now.Add(-time.Hour) + return &taxcode.TaxCode{ + NamespacedID: models.NamespacedID{Namespace: "test", ID: id}, + ManagedModel: models.ManagedModel{ + CreatedAt: now.Add(-2 * time.Hour), + UpdatedAt: now.Add(-2 * time.Hour), + DeletedAt: &deleted, + }, + Key: "tc-key", + Name: "Tax Code", + } +} + +func TestValidateRateCardsWithTaxCodes(t *testing.T) { + ctx := context.Background() + const taxCodeID = "01JBP3SGZ20Y7VRVC351TDFXYZ" + + t.Run("valid tax code returns no error", func(t *testing.T) { + resolver := stubTaxCodeResolver{ + namespace: "test", + result: validTaxCode(taxCodeID), + } + + cards := RateCards{makeFlatFeeRateCardWithTaxCodeID("rc-1", taxCodeID)} + err := ValidateRateCardsWithTaxCodes(ctx, resolver)(cards) + require.NoError(t, err) + }) + + t.Run("deleted tax code returns ErrCodeRateCardTaxCodeNotFound", func(t *testing.T) { + resolver := stubTaxCodeResolver{ + namespace: "test", + result: deletedTaxCode(taxCodeID), + } + + cards := RateCards{makeFlatFeeRateCardWithTaxCodeID("rc-1", taxCodeID)} + err := ValidateRateCardsWithTaxCodes(ctx, resolver)(cards) + require.Error(t, err) + + var vi models.ValidationIssue + require.True(t, errors.As(err, &vi), "expected ValidationIssue, got %T: %v", err, err) + require.Equal(t, ErrCodeRateCardTaxCodeNotFound, vi.Code()) + }) + + t.Run("not-found error from resolver returns ErrCodeRateCardTaxCodeNotFound", func(t *testing.T) { + resolver := stubTaxCodeResolver{ + namespace: "test", + err: taxcode.NewTaxCodeNotFoundError(taxCodeID), + } + + cards := RateCards{makeFlatFeeRateCardWithTaxCodeID("rc-1", taxCodeID)} + err := ValidateRateCardsWithTaxCodes(ctx, resolver)(cards) + require.Error(t, err) + + var vi models.ValidationIssue + require.True(t, errors.As(err, &vi), "expected ValidationIssue, got %T: %v", err, err) + require.Equal(t, ErrCodeRateCardTaxCodeNotFound, vi.Code()) + }) + + t.Run("rate card without TaxConfig is skipped", func(t *testing.T) { + resolver := stubTaxCodeResolver{ + namespace: "test", + // err set to ensure resolver is never called + err: errors.New("resolver should not be called"), + } + + cards := RateCards{makeFlatFeeRateCardNoTaxConfig("rc-no-tax")} + err := ValidateRateCardsWithTaxCodes(ctx, resolver)(cards) + require.NoError(t, err) + }) +} diff --git a/openmeter/productcatalog/testutils/env.go b/openmeter/productcatalog/testutils/env.go index 7e5576f398..a8516d662a 100644 --- a/openmeter/productcatalog/testutils/env.go +++ b/openmeter/productcatalog/testutils/env.go @@ -22,6 +22,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" planaddonadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter" planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" "github.com/openmeterio/openmeter/openmeter/taxcode" taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter" taxcodeservice "github.com/openmeterio/openmeter/openmeter/taxcode/service" @@ -117,6 +118,9 @@ func NewTestEnv(t *testing.T) *TestEnv { }) require.NoErrorf(t, err, "initializing tax code service must not fail") + taxCodeResolver, err := taxcoderesolver.New(taxCodeService) + require.NoErrorf(t, err, "failed to create tax code resolver: %v", err) + // Init plan service planAdapter, err := planadapter.New(planadapter.Config{ Client: client, @@ -128,6 +132,7 @@ func NewTestEnv(t *testing.T) *TestEnv { planService, err := planservice.New(planservice.Config{ Adapter: planAdapter, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, Logger: logger, Publisher: publisher, diff --git a/openmeter/subscription/testutils/service.go b/openmeter/subscription/testutils/service.go index d0adcdf6b9..9a9757040f 100644 --- a/openmeter/subscription/testutils/service.go +++ b/openmeter/subscription/testutils/service.go @@ -24,6 +24,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" planaddonrepo "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter" planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" "github.com/openmeterio/openmeter/openmeter/registry" registrybuilder "github.com/openmeterio/openmeter/openmeter/registry/builder" streamingtestutils "github.com/openmeterio/openmeter/openmeter/streaming/testutils" @@ -188,8 +189,12 @@ func NewService(t *testing.T, dbDeps *DBDeps) SubscriptionDependencies { featureResolver, err := featureresolver.New(entitlementRegistry.Feature) require.NoErrorf(t, err, "failed to create feature resolver: %v", err) + taxCodeResolver, err := taxcoderesolver.New(taxCodeService) + require.NoErrorf(t, err, "failed to create tax code resolver: %v", err) + planService, err := planservice.New(planservice.Config{ FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, Logger: logger, Adapter: planRepo, Publisher: publisher, diff --git a/openmeter/taxcode/service/hooks/planhook.go b/openmeter/taxcode/service/hooks/planhook.go index 7a3375a465..83bfe11429 100644 --- a/openmeter/taxcode/service/hooks/planhook.go +++ b/openmeter/taxcode/service/hooks/planhook.go @@ -51,6 +51,7 @@ func NewPlanHook(config PlanHookConfig) (PlanHook, error) { func (e *planHook) PreDelete(ctx context.Context, tc *taxcode.TaxCode) error { affectedPlans, err := e.planService.ListPlans(ctx, plan.ListPlansInput{ + Namespaces: []string{tc.Namespace}, Status: []productcatalog.PlanStatus{ productcatalog.PlanStatusActive, productcatalog.PlanStatusArchived, diff --git a/test/billing/subscription_suite.go b/test/billing/subscription_suite.go index 158cd7468c..a137f1b2b7 100644 --- a/test/billing/subscription_suite.go +++ b/test/billing/subscription_suite.go @@ -34,6 +34,7 @@ import ( planaddonrepo "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter" planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service" subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription/testutils" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" streamingtestutils "github.com/openmeterio/openmeter/openmeter/streaming/testutils" "github.com/openmeterio/openmeter/openmeter/subscription" subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" @@ -129,8 +130,12 @@ func (s *SubscriptionMixin) SetupSuite(t *testing.T, deps SubscriptionMixInDepen featureResolver, err := featureresolver.New(deps.FeatureService) require.NoErrorf(t, err, "failed to create feature resolver: %v", err) + taxCodeResolver, err := taxcoderesolver.New(taxCodeService) + require.NoErrorf(t, err, "failed to create tax code resolver: %v", err) + planService, err := planservice.New(planservice.Config{ FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, Adapter: planAdapter, TaxCode: taxCodeService, Logger: slog.Default(), diff --git a/test/customer/testenv.go b/test/customer/testenv.go index 4c4d627b16..ed98b47fe2 100644 --- a/test/customer/testenv.go +++ b/test/customer/testenv.go @@ -36,6 +36,7 @@ import ( planservice "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/service" planaddonrepo "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter" planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service" + "github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver" registrybuilder "github.com/openmeterio/openmeter/openmeter/registry/builder" streamingtestutils "github.com/openmeterio/openmeter/openmeter/streaming/testutils" "github.com/openmeterio/openmeter/openmeter/subject" @@ -275,9 +276,13 @@ func NewTestEnv(t *testing.T, ctx context.Context) (TestEnv, error) { featureResolver, err := featureresolver.New(entitlementRegistry.Feature) require.NoErrorf(t, err, "failed to create feature resolver: %v", err) + taxCodeResolver, err := taxcoderesolver.New(taxCodeService) + require.NoErrorf(t, err, "failed to create tax code resolver: %v", err) + planService, err := planservice.New(planservice.Config{ Adapter: planAdapter, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, Logger: logger.WithGroup("plan"), Publisher: publisher, From 5946f52f8aef51215e2956fec47b18d73727a66f Mon Sep 17 00:00:00 2001 From: Robert Borbely Date: Tue, 23 Jun 2026 22:35:41 +0200 Subject: [PATCH 2/3] feat(taxcode): prevent deletion of add-on-referenced tax codes --- app/common/taxcode.go | 23 ++++ cmd/server/wire.go | 2 + cmd/server/wire_gen.go | 14 +++ .../addon/adapter/adapter_test.go | 106 ++++++++++++++++ .../productcatalog/addon/adapter/addon.go | 9 ++ openmeter/productcatalog/addon/service.go | 7 ++ openmeter/taxcode/errors.go | 20 +++ openmeter/taxcode/service/hooks/addonhook.go | 82 +++++++++++++ .../taxcode/service/hooks/addonhook_test.go | 115 ++++++++++++++++++ 9 files changed, 378 insertions(+) create mode 100644 openmeter/taxcode/service/hooks/addonhook.go create mode 100644 openmeter/taxcode/service/hooks/addonhook_test.go diff --git a/app/common/taxcode.go b/app/common/taxcode.go index 3c7d122def..8d703aba65 100644 --- a/app/common/taxcode.go +++ b/app/common/taxcode.go @@ -11,6 +11,7 @@ import ( "github.com/openmeterio/openmeter/app/config" "github.com/openmeterio/openmeter/openmeter/app" entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" "github.com/openmeterio/openmeter/openmeter/taxcode" taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter" @@ -67,6 +68,28 @@ func NewTaxCodePlanServiceHook( return h, nil } +// TaxCodeAddonHook prevents deleting tax codes that are still referenced by add-ons. +type TaxCodeAddonHook taxcodehooks.AddonHook + +// NewTaxCodeAddonServiceHook builds the add-on-reference hook and registers it +// on the tax code service. It depends on both the add-on and tax code services so wire constructs +// it only after both exist, avoiding a construction cycle (add-on already depends on tax code). +func NewTaxCodeAddonServiceHook( + addonService addon.Service, + taxCodeService taxcode.Service, +) (TaxCodeAddonHook, error) { + h, err := taxcodehooks.NewAddonHook(taxcodehooks.AddonHookConfig{ + AddonService: addonService, + }) + if err != nil { + return nil, fmt.Errorf("failed to create tax code add-on hook: %w", err) + } + + taxCodeService.RegisterHooks(h) + + return h, nil +} + func NewTaxCodeNamespaceHandler( logger *slog.Logger, service taxcode.Service, diff --git a/cmd/server/wire.go b/cmd/server/wire.go index 9ce1228f9d..0ae4749c50 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -100,6 +100,7 @@ type Application struct { TaxCodeNamespaceHandler *taxcode.NamespaceHandler TaxCodeService taxcode.Service TaxCodePlanHook common.TaxCodePlanHook + TaxCodeAddonHook common.TaxCodeAddonHook TelemetryServer common.TelemetryServer TerminationChecker *common.TerminationChecker RuntimeMetricsCollector common.RuntimeMetricsCollector @@ -150,6 +151,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl common.TaxCode, common.TaxCodeNamespaceHandler, common.NewTaxCodePlanServiceHook, + common.NewTaxCodeAddonServiceHook, common.Subscription, common.Lockr, common.Secret, diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index c435cdd0cb..a044d116eb 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -788,6 +788,18 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } + taxCodeAddonHook, err := common.NewTaxCodeAddonServiceHook(addonService, taxcodeService) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } health := common.NewHealthChecker(logger) telemetryHandler := common.NewTelemetryHandler(metricsTelemetryConfig, health, logger) v10, cleanup9 := common.NewTelemetryServer(telemetryConfig, telemetryHandler) @@ -884,6 +896,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl TaxCodeNamespaceHandler: taxcodeNamespaceHandler, TaxCodeService: taxcodeService, TaxCodePlanHook: taxCodePlanHook, + TaxCodeAddonHook: taxCodeAddonHook, TelemetryServer: v10, TerminationChecker: terminationChecker, RuntimeMetricsCollector: runtimeMetricsCollector, @@ -958,6 +971,7 @@ type Application struct { TaxCodeNamespaceHandler *taxcode.NamespaceHandler TaxCodeService taxcode.Service TaxCodePlanHook common.TaxCodePlanHook + TaxCodeAddonHook common.TaxCodeAddonHook TelemetryServer common.TelemetryServer TerminationChecker *common.TerminationChecker RuntimeMetricsCollector common.RuntimeMetricsCollector diff --git a/openmeter/productcatalog/addon/adapter/adapter_test.go b/openmeter/productcatalog/addon/adapter/adapter_test.go index 93cce22813..701c732d37 100644 --- a/openmeter/productcatalog/addon/adapter/adapter_test.go +++ b/openmeter/productcatalog/addon/adapter/adapter_test.go @@ -10,11 +10,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/openmeterio/openmeter/openmeter/app" "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils" + "github.com/openmeterio/openmeter/openmeter/taxcode" + tctestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils" "github.com/openmeterio/openmeter/openmeter/testutils" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/datetime" @@ -438,5 +441,108 @@ func TestPostgresAdapter(t *testing.T) { }) } }) + + t.Run("ListTaxCodeFilter", func(t *testing.T) { + testListAddonTaxCodeFilter(t, env) + }) + }) +} + +func testListAddonTaxCodeFilter(t *testing.T, env *pctestutils.TestEnv) { + t.Helper() + + ns := pctestutils.NewTestNamespace(t) + + // Create two tax codes with distinct Stripe app mappings so the add-on service + // can resolve them and populate the rate-card tax_code_id FK. + tcEnv := tctestutils.NewTestEnvFromClient(t, env.Client, env.Logger) + + taxA := tcEnv.CreateTaxCode(t, ns, taxcode.CreateTaxCodeInput{ + Key: "tax-a", + Name: "Tax A", + AppMappings: taxcode.TaxCodeAppMappings{ + {AppType: app.AppTypeStripe, TaxCode: "txcd_20000001"}, + }, + }) + + taxB := tcEnv.CreateTaxCode(t, ns, taxcode.CreateTaxCodeInput{ + Key: "tax-b", + Name: "Tax B", + AppMappings: taxcode.TaxCodeAppMappings{ + {AppType: app.AppTypeStripe, TaxCode: "txcd_20000002"}, + }, + }) + + // Helper: build a minimal add-on input with a FlatFeeRateCard referencing the given taxCodeID. + makeAddonInput := func(key string, taxCodeID string) addon.CreateAddonInput { + input := pctestutils.NewTestAddon(t, ns, + &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "rc-1", + Name: "RC 1", + TaxConfig: &productcatalog.TaxConfig{ + TaxCodeID: lo.ToPtr(taxCodeID), + }, + }, + }, + ) + input.Addon.AddonMeta.Key = key + return input + } + + // given: two add-ons each referencing a different tax code + addonA, err := env.Addon.CreateAddon(t.Context(), makeAddonInput("addon-a", taxA.ID)) + require.NoError(t, err, "creating addonA must not fail") + + addonB, err := env.Addon.CreateAddon(t.Context(), makeAddonInput("addon-b", taxB.ID)) + require.NoError(t, err, "creating addonB must not fail") + + t.Run("filter by taxA returns only addonA", func(t *testing.T) { + // when: listing add-ons filtered by taxA + result, err := env.AddonRepository.ListAddons(t.Context(), addon.ListAddonsInput{ + Namespaces: []string{ns}, + TaxCodes: &filter.FilterString{In: lo.ToPtr([]string{taxA.ID})}, + Page: pagination.Page{PageSize: 100, PageNumber: 1}, + }) + + // then: only addonA is returned + require.NoError(t, err) + ids := addonIDs(result) + require.ElementsMatch(t, []string{addonA.ID}, ids) + }) + + t.Run("filter by taxB returns only addonB", func(t *testing.T) { + // when: listing add-ons filtered by taxB + result, err := env.AddonRepository.ListAddons(t.Context(), addon.ListAddonsInput{ + Namespaces: []string{ns}, + TaxCodes: &filter.FilterString{In: lo.ToPtr([]string{taxB.ID})}, + Page: pagination.Page{PageSize: 100, PageNumber: 1}, + }) + + // then: only addonB is returned + require.NoError(t, err) + ids := addonIDs(result) + require.ElementsMatch(t, []string{addonB.ID}, ids) }) + + t.Run("filter by nonexistent tax code returns empty", func(t *testing.T) { + // when: listing add-ons filtered by a tax code id that doesn't exist + result, err := env.AddonRepository.ListAddons(t.Context(), addon.ListAddonsInput{ + Namespaces: []string{ns}, + TaxCodes: &filter.FilterString{In: lo.ToPtr([]string{"01000000000000000000000000"})}, + Page: pagination.Page{PageSize: 100, PageNumber: 1}, + }) + + // then: no add-ons are returned + require.NoError(t, err) + require.Empty(t, result.Items) + }) +} + +func addonIDs(result pagination.Result[addon.Addon]) []string { + ids := make([]string, 0, len(result.Items)) + for _, a := range result.Items { + ids = append(ids, a.ID) + } + return ids } diff --git a/openmeter/productcatalog/addon/adapter/addon.go b/openmeter/productcatalog/addon/adapter/addon.go index 671813ce00..c5d85adc75 100644 --- a/openmeter/productcatalog/addon/adapter/addon.go +++ b/openmeter/productcatalog/addon/adapter/addon.go @@ -50,6 +50,15 @@ func (a *adapter) ListAddons(ctx context.Context, params addon.ListAddonsInput) query = filter.ApplyToQuery(query, params.Name, addondb.FieldName) query = filter.ApplyToQuery(query, params.Currency, addondb.FieldCurrency) + if params.TaxCodes != nil { + if p := filter.SelectPredicate[predicate.AddonRateCard](filter.Filter(*params.TaxCodes), addonratecarddb.FieldTaxCodeID); p != nil { + query = query.Where(addondb.HasRatecardsWith( + *p, + addonratecarddb.DeletedAtIsNil(), + )) + } + } + if !params.IncludeDeleted { query = query.Where(addondb.DeletedAtIsNil()) } diff --git a/openmeter/productcatalog/addon/service.go b/openmeter/productcatalog/addon/service.go index a236e43780..9ed4cd2939 100644 --- a/openmeter/productcatalog/addon/service.go +++ b/openmeter/productcatalog/addon/service.go @@ -95,6 +95,8 @@ type ListAddonsInput struct { Key *filter.FilterString Name *filter.FilterString Currency *filter.FilterString + // TaxCodes filters add-ons by the tax code IDs referenced on their rate cards. + TaxCodes *filter.FilterString } func (i ListAddonsInput) Validate() error { @@ -120,6 +122,11 @@ func (i ListAddonsInput) Validate() error { errs = append(errs, err) } } + if i.TaxCodes != nil { + if err := i.TaxCodes.Validate(); err != nil { + errs = append(errs, err) + } + } if i.OrderBy != "" { if err := i.OrderBy.Validate(); err != nil { diff --git a/openmeter/taxcode/errors.go b/openmeter/taxcode/errors.go index 785dc61fa8..8200057e4e 100644 --- a/openmeter/taxcode/errors.go +++ b/openmeter/taxcode/errors.go @@ -176,3 +176,23 @@ func IsTaxCodeReferencedByPlanError(err error) bool { var vi models.ValidationIssue return errors.As(err, &vi) && vi.Code() == ErrCodeTaxCodeReferencedByPlan } + +const ErrCodeTaxCodeReferencedByAddon models.ErrorCode = "tax_code_referenced_by_addon" + +var ErrTaxCodeReferencedByAddon = models.NewValidationIssue( + ErrCodeTaxCodeReferencedByAddon, + "tax code cannot be deleted as it is referenced by one or more add-ons", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusConflict), +) + +func NewTaxCodeReferencedByAddonError(taxCodeID string, addonIDs []string) error { + return ErrTaxCodeReferencedByAddon. + WithAttr("id", taxCodeID). + WithAttr("addon_ids", addonIDs) +} + +func IsTaxCodeReferencedByAddonError(err error) bool { + var vi models.ValidationIssue + return errors.As(err, &vi) && vi.Code() == ErrCodeTaxCodeReferencedByAddon +} diff --git a/openmeter/taxcode/service/hooks/addonhook.go b/openmeter/taxcode/service/hooks/addonhook.go new file mode 100644 index 0000000000..da8edef4ea --- /dev/null +++ b/openmeter/taxcode/service/hooks/addonhook.go @@ -0,0 +1,82 @@ +package hooks + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + AddonHook = models.ServiceHook[taxcode.TaxCode] + NoopAddonHook = models.NoopServiceHook[taxcode.TaxCode] +) + +type AddonHookConfig struct { + AddonService addon.Service +} + +func (e AddonHookConfig) Validate() error { + if e.AddonService == nil { + return fmt.Errorf("addon service is required") + } + + return nil +} + +var _ models.ServiceHook[taxcode.TaxCode] = (*addonHook)(nil) + +type addonHook struct { + NoopAddonHook + + addonService addon.Service +} + +func NewAddonHook(config AddonHookConfig) (AddonHook, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid addon hook config: %w", err) + } + + return &addonHook{ + addonService: config.AddonService, + }, nil +} + +func (e *addonHook) PreDelete(ctx context.Context, tc *taxcode.TaxCode) error { + affectedAddons, err := e.addonService.ListAddons(ctx, addon.ListAddonsInput{ + Namespaces: []string{tc.Namespace}, + Status: []productcatalog.AddonStatus{ + productcatalog.AddonStatusActive, + productcatalog.AddonStatusDraft, + productcatalog.AddonStatusArchived, + }, + TaxCodes: &filter.FilterString{ + In: &[]string{ + tc.ID, + }, + }, + Page: pagination.Page{ + PageSize: 5, + PageNumber: 1, + }, + }) + if err != nil { + return fmt.Errorf("failed to list add-ons: %w", err) + } + + if len(affectedAddons.Items) > 0 { + addonIDs := lo.Map(affectedAddons.Items, func(item addon.Addon, _ int) string { + return item.ID + }) + return taxcode.NewTaxCodeReferencedByAddonError(tc.ID, addonIDs) + } + + return nil +} diff --git a/openmeter/taxcode/service/hooks/addonhook_test.go b/openmeter/taxcode/service/hooks/addonhook_test.go new file mode 100644 index 0000000000..266ed17724 --- /dev/null +++ b/openmeter/taxcode/service/hooks/addonhook_test.go @@ -0,0 +1,115 @@ +package hooks_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/openmeter/taxcode/service/hooks" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +// stubAddonService implements addon.Service for testing the addon hook. +// Only ListAddons has real behavior; all other methods panic. +type stubAddonService struct { + listResult pagination.Result[addon.Addon] + listErr error + lastInput addon.ListAddonsInput +} + +func (s *stubAddonService) ListAddons(ctx context.Context, params addon.ListAddonsInput) (pagination.Result[addon.Addon], error) { + s.lastInput = params + return s.listResult, s.listErr +} + +func (s *stubAddonService) CreateAddon(_ context.Context, _ addon.CreateAddonInput) (*addon.Addon, error) { + panic("not implemented") +} + +func (s *stubAddonService) DeleteAddon(_ context.Context, _ addon.DeleteAddonInput) error { + panic("not implemented") +} + +func (s *stubAddonService) GetAddon(_ context.Context, _ addon.GetAddonInput) (*addon.Addon, error) { + panic("not implemented") +} + +func (s *stubAddonService) UpdateAddon(_ context.Context, _ addon.UpdateAddonInput) (*addon.Addon, error) { + panic("not implemented") +} + +func (s *stubAddonService) PublishAddon(_ context.Context, _ addon.PublishAddonInput) (*addon.Addon, error) { + panic("not implemented") +} + +func (s *stubAddonService) ArchiveAddon(_ context.Context, _ addon.ArchiveAddonInput) (*addon.Addon, error) { + panic("not implemented") +} + +func (s *stubAddonService) NextAddon(_ context.Context, _ addon.NextAddonInput) (*addon.Addon, error) { + panic("not implemented") +} + +var _ addon.Service = (*stubAddonService)(nil) + +const addonTestTaxCodeID = "01234567890123456789012346" + +func TestAddonHook_PreDelete(t *testing.T) { + tc := &taxcode.TaxCode{ + NamespacedID: models.NamespacedID{ + Namespace: "test-ns", + ID: addonTestTaxCodeID, + }, + } + + t.Run("blocks deletion when an add-on references the tax code", func(t *testing.T) { + // given: an addon service that returns one matching add-on + stub := &stubAddonService{ + listResult: pagination.Result[addon.Addon]{ + Items: []addon.Addon{ + {ManagedModel: models.ManagedModel{}, NamespacedID: models.NamespacedID{ID: "addon-abc"}}, + }, + TotalCount: 1, + }, + } + + hook, err := hooks.NewAddonHook(hooks.AddonHookConfig{AddonService: stub}) + require.NoError(t, err) + + // when: PreDelete is called + err = hook.PreDelete(t.Context(), tc) + + // then: an error is returned and it is a TaxCodeReferencedByAddon error + require.Error(t, err) + require.True(t, taxcode.IsTaxCodeReferencedByAddonError(err), + "expected TaxCodeReferencedByAddon error, got: %v", err) + + // and: the stub received a ListAddonsInput whose TaxCodes.In contains the tax code id + require.NotNil(t, stub.lastInput.TaxCodes) + require.NotNil(t, stub.lastInput.TaxCodes.In) + require.Contains(t, *stub.lastInput.TaxCodes.In, addonTestTaxCodeID) + }) + + t.Run("allows deletion when no add-on references the tax code", func(t *testing.T) { + // given: an addon service that returns no matching add-ons + stub := &stubAddonService{ + listResult: pagination.Result[addon.Addon]{ + Items: []addon.Addon{}, + TotalCount: 0, + }, + } + + hook, err := hooks.NewAddonHook(hooks.AddonHookConfig{AddonService: stub}) + require.NoError(t, err) + + // when: PreDelete is called + err = hook.PreDelete(t.Context(), tc) + + // then: no error is returned + require.NoError(t, err) + }) +} From 16973cc30274cab2d527f1e5c360df58e013b957 Mon Sep 17 00:00:00 2001 From: Robert Borbely Date: Tue, 23 Jun 2026 23:19:51 +0200 Subject: [PATCH 3/3] feat(taxcode): validate add-on tax codes on publish --- app/common/productcatalog.go | 2 ++ cmd/billing-worker/wire_gen.go | 2 +- cmd/jobs/internal/wire_gen.go | 2 +- cmd/server/wire_gen.go | 23 +++++++++---------- openmeter/productcatalog/addon.go | 6 +++++ .../productcatalog/addon/service/addon.go | 10 ++++++++ .../productcatalog/addon/service/service.go | 7 ++++++ openmeter/productcatalog/testutils/env.go | 1 + openmeter/subscription/testutils/service.go | 1 + test/billing/subscription_suite.go | 1 + test/customer/testenv.go | 1 + 11 files changed, 42 insertions(+), 14 deletions(-) diff --git a/app/common/productcatalog.go b/app/common/productcatalog.go index c69e5fd676..69da868624 100644 --- a/app/common/productcatalog.go +++ b/app/common/productcatalog.go @@ -118,6 +118,7 @@ func NewAddonService( logger *slog.Logger, db *entdb.Client, featureResolver productcatalog.FeatureResolver, + taxCodeResolver productcatalog.TaxCodeResolver, taxCodeService taxcode.Service, publisher eventbus.Publisher, ) (addon.Service, error) { @@ -132,6 +133,7 @@ func NewAddonService( return addonservice.New(addonservice.Config{ Adapter: adapter, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, Logger: logger.With("subsystem", "productcatalog.addon"), Publisher: publisher, diff --git a/cmd/billing-worker/wire_gen.go b/cmd/billing-worker/wire_gen.go index 6ff109e8bb..2d7460a73f 100644 --- a/cmd/billing-worker/wire_gen.go +++ b/cmd/billing-worker/wire_gen.go @@ -302,7 +302,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - addonService, err := common.NewAddonService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + addonService, err := common.NewAddonService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup7() cleanup6() diff --git a/cmd/jobs/internal/wire_gen.go b/cmd/jobs/internal/wire_gen.go index c78b9f0c99..86a723f8fd 100644 --- a/cmd/jobs/internal/wire_gen.go +++ b/cmd/jobs/internal/wire_gen.go @@ -311,7 +311,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - addonService, err := common.NewAddonService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + addonService, err := common.NewAddonService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup7() cleanup6() diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index a044d116eb..8187abf45b 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -182,7 +182,17 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - addonService, err := common.NewAddonService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + taxCodeResolver, err := taxcoderesolver.New(taxcodeService) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + addonService, err := common.NewAddonService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup6() cleanup5() @@ -295,17 +305,6 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl cleanup() return Application{}, nil, err } - taxCodeResolver, err := taxcoderesolver.New(taxcodeService) - if err != nil { - cleanup7() - cleanup6() - cleanup5() - cleanup4() - cleanup3() - cleanup2() - cleanup() - return Application{}, nil, err - } planService, err := common.NewPlanService(logger, client, featureResolver, taxCodeResolver, taxcodeService, eventbusPublisher) if err != nil { cleanup7() diff --git a/openmeter/productcatalog/addon.go b/openmeter/productcatalog/addon.go index c79f9544a0..6647983aec 100644 --- a/openmeter/productcatalog/addon.go +++ b/openmeter/productcatalog/addon.go @@ -332,3 +332,9 @@ func ValidateAddonWithFeatures(ctx context.Context, resolver NamespacedFeatureRe return ValidateRateCardsWithFeatures(ctx, resolver)(a.RateCards) } } + +func ValidateAddonWithTaxCodes(ctx context.Context, resolver NamespacedTaxCodeResolver) models.ValidatorFunc[Addon] { + return func(a Addon) error { + return ValidateRateCardsWithTaxCodes(ctx, resolver)(a.RateCards) + } +} diff --git a/openmeter/productcatalog/addon/service/addon.go b/openmeter/productcatalog/addon/service/addon.go index dfd4837ab7..3bd630ceab 100644 --- a/openmeter/productcatalog/addon/service/addon.go +++ b/openmeter/productcatalog/addon/service/addon.go @@ -426,6 +426,16 @@ func (s service) PublishAddon(ctx context.Context, params addon.PublishAddonInpu ) } + // Validate add-on with tax codes + err = pa.ValidateWith( + productcatalog.ValidateAddonWithTaxCodes(ctx, s.taxCodeResolver.WithNamespace(params.Namespace)), + ) + if err != nil { + errs = append(errs, fmt.Errorf("invalid add-on [id=%s key=%s version=%d]: %w", + add.ID, add.Key, add.Version, err), + ) + } + if err = errors.Join(errs...); err != nil { return nil, models.NewGenericValidationError(err) } diff --git a/openmeter/productcatalog/addon/service/service.go b/openmeter/productcatalog/addon/service/service.go index 82c4ee0574..d37f11cacb 100644 --- a/openmeter/productcatalog/addon/service/service.go +++ b/openmeter/productcatalog/addon/service/service.go @@ -17,6 +17,7 @@ type Config struct { Publisher eventbus.Publisher FeatureResolver productcatalog.FeatureResolver + TaxCodeResolver productcatalog.TaxCodeResolver } func New(config Config) (addon.Service, error) { @@ -28,6 +29,10 @@ func New(config Config) (addon.Service, error) { return nil, errors.New("feature resolver is required") } + if config.TaxCodeResolver == nil { + return nil, errors.New("tax code resolver is required") + } + if config.TaxCode == nil { return nil, errors.New("tax code service is required") } @@ -47,6 +52,7 @@ func New(config Config) (addon.Service, error) { publisher: config.Publisher, featureResolver: config.FeatureResolver, + taxCodeResolver: config.TaxCodeResolver, }, nil } @@ -59,4 +65,5 @@ type service struct { publisher eventbus.Publisher featureResolver productcatalog.FeatureResolver + taxCodeResolver productcatalog.TaxCodeResolver } diff --git a/openmeter/productcatalog/testutils/env.go b/openmeter/productcatalog/testutils/env.go index a8516d662a..66cc5bdb02 100644 --- a/openmeter/productcatalog/testutils/env.go +++ b/openmeter/productcatalog/testutils/env.go @@ -151,6 +151,7 @@ func NewTestEnv(t *testing.T) *TestEnv { addonService, err := addonservice.New(addonservice.Config{ Adapter: addonAdapter, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, Logger: logger, Publisher: publisher, diff --git a/openmeter/subscription/testutils/service.go b/openmeter/subscription/testutils/service.go index 9a9757040f..0fd95583e3 100644 --- a/openmeter/subscription/testutils/service.go +++ b/openmeter/subscription/testutils/service.go @@ -234,6 +234,7 @@ func NewService(t *testing.T, dbDeps *DBDeps) SubscriptionDependencies { Logger: logger, Publisher: publisher, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, }) require.NoError(t, err) diff --git a/test/billing/subscription_suite.go b/test/billing/subscription_suite.go index a137f1b2b7..0abaca7774 100644 --- a/test/billing/subscription_suite.go +++ b/test/billing/subscription_suite.go @@ -186,6 +186,7 @@ func (s *SubscriptionMixin) SetupSuite(t *testing.T, deps SubscriptionMixInDepen Logger: slog.Default(), Publisher: publisher, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, }) require.NoError(t, err) diff --git a/test/customer/testenv.go b/test/customer/testenv.go index ed98b47fe2..11c17d9736 100644 --- a/test/customer/testenv.go +++ b/test/customer/testenv.go @@ -347,6 +347,7 @@ func NewTestEnv(t *testing.T, ctx context.Context) (TestEnv, error) { Logger: logger, Publisher: publisher, FeatureResolver: featureResolver, + TaxCodeResolver: taxCodeResolver, TaxCode: taxCodeService, }) require.NoError(t, err)