Skip to content

Commit e193df7

Browse files
feat(taxcode): prevent deletion of plan-referenced tax codes and validate tax codes on plan publish (#4557)
1 parent 96f7d97 commit e193df7

17 files changed

Lines changed: 534 additions & 3 deletions

File tree

app/common/productcatalog.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon"
2626
planaddonadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter"
2727
planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service"
28+
"github.com/openmeterio/openmeter/openmeter/productcatalog/taxcoderesolver"
2829
"github.com/openmeterio/openmeter/openmeter/streaming"
2930
"github.com/openmeterio/openmeter/openmeter/taxcode"
3031
"github.com/openmeterio/openmeter/openmeter/watermill/eventbus"
@@ -49,6 +50,7 @@ var Cost = wire.NewSet(
4950

5051
var Plan = wire.NewSet(
5152
NewPlanService,
53+
NewTaxCodeResolver,
5254
)
5355

5456
var Addon = wire.NewSet(
@@ -71,6 +73,8 @@ func NewFeatureConnector(
7173

7274
var NewFeatureResolver = featureresolver.New
7375

76+
var NewTaxCodeResolver = taxcoderesolver.New
77+
7478
func NewCostService(
7579
featureConnector feature.FeatureConnector,
7680
meterService meter.Service,
@@ -88,6 +92,7 @@ func NewPlanService(
8892
logger *slog.Logger,
8993
db *entdb.Client,
9094
featureResolver productcatalog.FeatureResolver,
95+
taxCodeResolver productcatalog.TaxCodeResolver,
9196
taxCodeService taxcode.Service,
9297
publisher eventbus.Publisher,
9398
) (plan.Service, error) {
@@ -102,6 +107,7 @@ func NewPlanService(
102107
return planservice.New(planservice.Config{
103108
Adapter: adapter,
104109
FeatureResolver: featureResolver,
110+
TaxCodeResolver: taxCodeResolver,
105111
TaxCode: taxCodeService,
106112
Logger: logger.With("subsystem", "productcatalog.plan"),
107113
Publisher: publisher,

cmd/billing-worker/wire_gen.go

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/jobs/internal/wire_gen.go

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/server/wire_gen.go

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/productcatalog/errors.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ var ErrRateCardFeatureArchived = models.NewValidationIssue(
121121
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
122122
)
123123

124+
const ErrCodeRateCardTaxCodeNotFound models.ErrorCode = "rate_card_tax_code_not_found"
125+
126+
var ErrRateCardTaxCodeNotFound = models.NewValidationIssue(
127+
ErrCodeRateCardTaxCodeNotFound,
128+
"tax code not found",
129+
models.WithFieldString("taxConfig"),
130+
models.WithCriticalSeverity(),
131+
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
132+
)
133+
124134
const ErrCodeRateCardFeatureMismatch models.ErrorCode = "rate_card_feature_mismatch"
125135

126136
var ErrRateCardFeatureMismatch = models.NewValidationIssue(

openmeter/productcatalog/plan.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,24 @@ func ValidatePlanWithFeatures(ctx context.Context, resolver NamespacedFeatureRes
365365
return errors.Join(errs...)
366366
}
367367
}
368+
369+
func ValidatePlanWithTaxCodes(ctx context.Context, resolver NamespacedTaxCodeResolver) models.ValidatorFunc[Plan] {
370+
return func(p Plan) error {
371+
var errs []error
372+
373+
for _, phase := range p.Phases {
374+
phaseFieldSelector := models.NewFieldSelectorGroup(
375+
models.NewFieldSelector("phases").
376+
WithExpression(
377+
models.NewFieldAttrValue("key", phase.Key),
378+
),
379+
)
380+
381+
if err := ValidateRateCardsWithTaxCodes(ctx, resolver)(phase.RateCards); err != nil {
382+
errs = append(errs, models.ErrorWithFieldPrefix(phaseFieldSelector, err))
383+
}
384+
}
385+
386+
return errors.Join(errs...)
387+
}
388+
}

openmeter/productcatalog/plan/service/plan.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,16 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput)
437437
)
438438
}
439439

440+
// Validate plan with tax codes
441+
err = pp.ValidateWith(
442+
productcatalog.ValidatePlanWithTaxCodes(ctx, s.taxCodeResolver.WithNamespace(params.Namespace)),
443+
)
444+
if err != nil {
445+
errs = append(errs, fmt.Errorf("invalid plan [id=%s key=%s version=%d]: %w",
446+
p.ID, p.Key, p.Version, err),
447+
)
448+
}
449+
440450
// Check for incompatible add-ons assigned to this plan
441451

442452
if p.Addons == nil {

openmeter/productcatalog/plan/service/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Config struct {
1717
Publisher eventbus.Publisher
1818

1919
FeatureResolver productcatalog.FeatureResolver
20+
TaxCodeResolver productcatalog.TaxCodeResolver
2021
}
2122

2223
func New(config Config) (plan.Service, error) {
@@ -28,6 +29,10 @@ func New(config Config) (plan.Service, error) {
2829
return nil, errors.New("feature resolver is required")
2930
}
3031

32+
if config.TaxCodeResolver == nil {
33+
return nil, errors.New("tax code resolver is required")
34+
}
35+
3136
if config.Logger == nil {
3237
return nil, errors.New("logger is required")
3338
}
@@ -47,6 +52,7 @@ func New(config Config) (plan.Service, error) {
4752
publisher: config.Publisher,
4853

4954
featureResolver: config.FeatureResolver,
55+
taxCodeResolver: config.TaxCodeResolver,
5056
}, nil
5157
}
5258

@@ -59,4 +65,5 @@ type service struct {
5965
publisher eventbus.Publisher
6066

6167
featureResolver productcatalog.FeatureResolver
68+
taxCodeResolver productcatalog.TaxCodeResolver
6269
}

openmeter/productcatalog/plan/service/taxcode_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service_test
22

33
import (
44
"context"
5+
"errors"
56
"testing"
67
"time"
78

@@ -885,6 +886,119 @@ func TestPlanTaxCodeBackfill(t *testing.T) {
885886
})
886887
}
887888

889+
func TestPlanPublishRejectsDeletedTaxCode(t *testing.T) {
890+
MonthPeriod := datetime.MustParseDuration(t, "P1M")
891+
892+
ctx, cancel := context.WithCancel(context.Background())
893+
defer cancel()
894+
895+
env := pctestutils.NewTestEnv(t)
896+
t.Cleanup(func() { env.Close(t) })
897+
env.DBSchemaMigrate(t)
898+
899+
namespace := pctestutils.NewTestNamespace(t)
900+
901+
// given:
902+
// - meters and a feature are provisioned
903+
// - a tax code is created and then deleted, leaving a dangling reference
904+
// - a draft plan has a rate card referencing that (now-deleted) tax code
905+
906+
err := env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace))
907+
require.NoError(t, err)
908+
909+
result, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
910+
Page: pagination.Page{
911+
PageSize: 1000,
912+
PageNumber: 1,
913+
},
914+
Namespace: namespace,
915+
})
916+
require.NoError(t, err)
917+
require.NotEmpty(t, result.Items)
918+
919+
features := make([]feature.Feature, 0, len(result.Items))
920+
for _, m := range result.Items {
921+
feat, err := env.Feature.CreateFeature(ctx, pctestutils.NewTestFeatureFromMeter(t, &m))
922+
require.NoError(t, err)
923+
features = append(features, feat)
924+
}
925+
926+
// Provision organization-default tax codes so DeleteTaxCode can proceed past the org-defaults check.
927+
invoicingTC, err := env.TaxCode.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
928+
Namespace: namespace,
929+
Key: "org-default-invoicing",
930+
Name: "Org Default Invoicing",
931+
AppMappings: taxcode.TaxCodeAppMappings{
932+
{AppType: app.AppTypeStripe, TaxCode: "txcd_00000001"},
933+
},
934+
})
935+
require.NoError(t, err)
936+
937+
creditGrantTC, err := env.TaxCode.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
938+
Namespace: namespace,
939+
Key: "org-default-credit-grant",
940+
Name: "Org Default Credit Grant",
941+
AppMappings: taxcode.TaxCodeAppMappings{
942+
{AppType: app.AppTypeStripe, TaxCode: "txcd_00000002"},
943+
},
944+
})
945+
require.NoError(t, err)
946+
947+
_, err = env.TaxCode.UpsertOrganizationDefaultTaxCodes(ctx, taxcode.UpsertOrganizationDefaultTaxCodesInput{
948+
Namespace: namespace,
949+
InvoicingTaxCodeID: invoicingTC.ID,
950+
CreditGrantTaxCodeID: creditGrantTC.ID,
951+
})
952+
require.NoError(t, err)
953+
954+
// Create a tax code with a Stripe app mapping so it resolves at create time.
955+
tcEntity, err := env.TaxCode.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
956+
Namespace: namespace,
957+
Key: "stripe_txcd_40000001",
958+
Name: "txcd_40000001",
959+
AppMappings: taxcode.TaxCodeAppMappings{
960+
{AppType: app.AppTypeStripe, TaxCode: "txcd_40000001"},
961+
},
962+
})
963+
require.NoError(t, err)
964+
965+
taxCodeID := tcEntity.ID
966+
967+
// Create a DRAFT plan with a rate card referencing the tax code.
968+
input := newTestPlanInput(t, namespace, newTestFlatRateCard(features[0], &productcatalog.TaxConfig{
969+
TaxCodeID: lo.ToPtr(taxCodeID),
970+
}, &MonthPeriod))
971+
input.Key = "publish-deleted-taxcode"
972+
input.Name = "Publish Deleted TaxCode"
973+
974+
p, err := env.Plan.CreatePlan(ctx, input)
975+
require.NoError(t, err)
976+
977+
// Delete the tax code — the plan-reference delete hook is NOT registered in pctestutils,
978+
// so this succeeds and leaves a dangling reference (intended for this test).
979+
err = env.TaxCode.DeleteTaxCode(ctx, taxcode.DeleteTaxCodeInput{
980+
NamespacedID: models.NamespacedID{Namespace: namespace, ID: taxCodeID},
981+
})
982+
require.NoError(t, err)
983+
984+
// when: publishing the plan
985+
publishAt := time.Now().Truncate(time.Microsecond)
986+
_, err = env.Plan.PublishPlan(ctx, plan.PublishPlanInput{
987+
NamespacedID: p.NamespacedID,
988+
EffectivePeriod: productcatalog.EffectivePeriod{
989+
EffectiveFrom: &publishAt,
990+
EffectiveTo: nil,
991+
},
992+
})
993+
994+
// then: publish fails with a rate-card tax-code-not-found validation issue
995+
require.Error(t, err)
996+
997+
var vi models.ValidationIssue
998+
require.True(t, errors.As(err, &vi), "expected ValidationIssue wrapping ErrCodeRateCardTaxCodeNotFound, got %T: %v", err, err)
999+
require.Equal(t, productcatalog.ErrCodeRateCardTaxCodeNotFound, vi.Code())
1000+
}
1001+
8881002
func TestPlanWithAddonTaxCode(t *testing.T) {
8891003
MonthPeriod := datetime.MustParseDuration(t, "P1M")
8901004

0 commit comments

Comments
 (0)