Skip to content

Commit e850ada

Browse files
committed
feat(taxcode): prevent deletion of add-on-referenced tax codes
1 parent 8a31078 commit e850ada

9 files changed

Lines changed: 378 additions & 0 deletions

File tree

app/common/taxcode.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/openmeterio/openmeter/app/config"
1212
"github.com/openmeterio/openmeter/openmeter/app"
1313
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
14+
"github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
1415
"github.com/openmeterio/openmeter/openmeter/productcatalog/plan"
1516
"github.com/openmeterio/openmeter/openmeter/taxcode"
1617
taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter"
@@ -67,6 +68,28 @@ func NewTaxCodePlanValidatorServiceHook(
6768
return h, nil
6869
}
6970

71+
// TaxCodeAddonHook prevents deleting tax codes that are still referenced by add-ons.
72+
type TaxCodeAddonHook taxcodehooks.AddonHook
73+
74+
// NewTaxCodeAddonServiceHook builds the add-on-reference hook and registers it
75+
// on the tax code service. It depends on both the add-on and tax code services so wire constructs
76+
// it only after both exist, avoiding a construction cycle (add-on already depends on tax code).
77+
func NewTaxCodeAddonServiceHook(
78+
addonService addon.Service,
79+
taxCodeService taxcode.Service,
80+
) (TaxCodeAddonHook, error) {
81+
h, err := taxcodehooks.NewAddonHook(taxcodehooks.AddonHookConfig{
82+
AddonService: addonService,
83+
})
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to create tax code add-on hook: %w", err)
86+
}
87+
88+
taxCodeService.RegisterHooks(h)
89+
90+
return h, nil
91+
}
92+
7093
func NewTaxCodeNamespaceHandler(
7194
logger *slog.Logger,
7295
service taxcode.Service,

cmd/server/wire.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ type Application struct {
100100
TaxCodeNamespaceHandler *taxcode.NamespaceHandler
101101
TaxCodeService taxcode.Service
102102
TaxCodePlanValidatorHook common.TaxCodePlanValidatorHook
103+
TaxCodeAddonHook common.TaxCodeAddonHook
103104
TelemetryServer common.TelemetryServer
104105
TerminationChecker *common.TerminationChecker
105106
RuntimeMetricsCollector common.RuntimeMetricsCollector
@@ -150,6 +151,7 @@ func initializeApplication(ctx context.Context, conf config.Configuration) (Appl
150151
common.TaxCode,
151152
common.TaxCodeNamespaceHandler,
152153
common.NewTaxCodePlanValidatorServiceHook,
154+
common.NewTaxCodeAddonServiceHook,
153155
common.Subscription,
154156
common.Lockr,
155157
common.Secret,

cmd/server/wire_gen.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/productcatalog/addon/adapter/adapter_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import (
1010
"github.com/stretchr/testify/assert"
1111
"github.com/stretchr/testify/require"
1212

13+
"github.com/openmeterio/openmeter/openmeter/app"
1314
"github.com/openmeterio/openmeter/openmeter/meter"
1415
"github.com/openmeterio/openmeter/openmeter/productcatalog"
1516
"github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
1617
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
1718
pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils"
19+
"github.com/openmeterio/openmeter/openmeter/taxcode"
20+
tctestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils"
1821
"github.com/openmeterio/openmeter/openmeter/testutils"
1922
"github.com/openmeterio/openmeter/pkg/clock"
2023
"github.com/openmeterio/openmeter/pkg/datetime"
@@ -438,5 +441,108 @@ func TestPostgresAdapter(t *testing.T) {
438441
})
439442
}
440443
})
444+
445+
t.Run("ListTaxCodeFilter", func(t *testing.T) {
446+
testListAddonTaxCodeFilter(t, env)
447+
})
448+
})
449+
}
450+
451+
func testListAddonTaxCodeFilter(t *testing.T, env *pctestutils.TestEnv) {
452+
t.Helper()
453+
454+
ns := pctestutils.NewTestNamespace(t)
455+
456+
// Create two tax codes with distinct Stripe app mappings so the add-on service
457+
// can resolve them and populate the rate-card tax_code_id FK.
458+
tcEnv := tctestutils.NewTestEnvFromClient(t, env.Client, env.Logger)
459+
460+
taxA := tcEnv.CreateTaxCode(t, ns, taxcode.CreateTaxCodeInput{
461+
Key: "tax-a",
462+
Name: "Tax A",
463+
AppMappings: taxcode.TaxCodeAppMappings{
464+
{AppType: app.AppTypeStripe, TaxCode: "txcd_20000001"},
465+
},
466+
})
467+
468+
taxB := tcEnv.CreateTaxCode(t, ns, taxcode.CreateTaxCodeInput{
469+
Key: "tax-b",
470+
Name: "Tax B",
471+
AppMappings: taxcode.TaxCodeAppMappings{
472+
{AppType: app.AppTypeStripe, TaxCode: "txcd_20000002"},
473+
},
474+
})
475+
476+
// Helper: build a minimal add-on input with a FlatFeeRateCard referencing the given taxCodeID.
477+
makeAddonInput := func(key string, taxCodeID string) addon.CreateAddonInput {
478+
input := pctestutils.NewTestAddon(t, ns,
479+
&productcatalog.FlatFeeRateCard{
480+
RateCardMeta: productcatalog.RateCardMeta{
481+
Key: "rc-1",
482+
Name: "RC 1",
483+
TaxConfig: &productcatalog.TaxConfig{
484+
TaxCodeID: lo.ToPtr(taxCodeID),
485+
},
486+
},
487+
},
488+
)
489+
input.Addon.AddonMeta.Key = key
490+
return input
491+
}
492+
493+
// given: two add-ons each referencing a different tax code
494+
addonA, err := env.Addon.CreateAddon(t.Context(), makeAddonInput("addon-a", taxA.ID))
495+
require.NoError(t, err, "creating addonA must not fail")
496+
497+
addonB, err := env.Addon.CreateAddon(t.Context(), makeAddonInput("addon-b", taxB.ID))
498+
require.NoError(t, err, "creating addonB must not fail")
499+
500+
t.Run("filter by taxA returns only addonA", func(t *testing.T) {
501+
// when: listing add-ons filtered by taxA
502+
result, err := env.AddonRepository.ListAddons(t.Context(), addon.ListAddonsInput{
503+
Namespaces: []string{ns},
504+
TaxCodes: &filter.FilterString{In: lo.ToPtr([]string{taxA.ID})},
505+
Page: pagination.Page{PageSize: 100, PageNumber: 1},
506+
})
507+
508+
// then: only addonA is returned
509+
require.NoError(t, err)
510+
ids := addonIDs(result)
511+
require.ElementsMatch(t, []string{addonA.ID}, ids)
512+
})
513+
514+
t.Run("filter by taxB returns only addonB", func(t *testing.T) {
515+
// when: listing add-ons filtered by taxB
516+
result, err := env.AddonRepository.ListAddons(t.Context(), addon.ListAddonsInput{
517+
Namespaces: []string{ns},
518+
TaxCodes: &filter.FilterString{In: lo.ToPtr([]string{taxB.ID})},
519+
Page: pagination.Page{PageSize: 100, PageNumber: 1},
520+
})
521+
522+
// then: only addonB is returned
523+
require.NoError(t, err)
524+
ids := addonIDs(result)
525+
require.ElementsMatch(t, []string{addonB.ID}, ids)
441526
})
527+
528+
t.Run("filter by nonexistent tax code returns empty", func(t *testing.T) {
529+
// when: listing add-ons filtered by a tax code id that doesn't exist
530+
result, err := env.AddonRepository.ListAddons(t.Context(), addon.ListAddonsInput{
531+
Namespaces: []string{ns},
532+
TaxCodes: &filter.FilterString{In: lo.ToPtr([]string{"01000000000000000000000000"})},
533+
Page: pagination.Page{PageSize: 100, PageNumber: 1},
534+
})
535+
536+
// then: no add-ons are returned
537+
require.NoError(t, err)
538+
require.Empty(t, result.Items)
539+
})
540+
}
541+
542+
func addonIDs(result pagination.Result[addon.Addon]) []string {
543+
ids := make([]string, 0, len(result.Items))
544+
for _, a := range result.Items {
545+
ids = append(ids, a.ID)
546+
}
547+
return ids
442548
}

openmeter/productcatalog/addon/adapter/addon.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ func (a *adapter) ListAddons(ctx context.Context, params addon.ListAddonsInput)
5050
query = filter.ApplyToQuery(query, params.Name, addondb.FieldName)
5151
query = filter.ApplyToQuery(query, params.Currency, addondb.FieldCurrency)
5252

53+
if params.TaxCodes != nil {
54+
if p := filter.SelectPredicate[predicate.AddonRateCard](filter.Filter(*params.TaxCodes), addonratecarddb.FieldTaxCodeID); p != nil {
55+
query = query.Where(addondb.HasRatecardsWith(
56+
*p,
57+
addonratecarddb.DeletedAtIsNil(),
58+
))
59+
}
60+
}
61+
5362
if !params.IncludeDeleted {
5463
query = query.Where(addondb.DeletedAtIsNil())
5564
}

openmeter/productcatalog/addon/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ type ListAddonsInput struct {
9595
Key *filter.FilterString
9696
Name *filter.FilterString
9797
Currency *filter.FilterString
98+
// TaxCodes filters add-ons by the tax code IDs referenced on their rate cards.
99+
TaxCodes *filter.FilterString
98100
}
99101

100102
func (i ListAddonsInput) Validate() error {
@@ -120,6 +122,11 @@ func (i ListAddonsInput) Validate() error {
120122
errs = append(errs, err)
121123
}
122124
}
125+
if i.TaxCodes != nil {
126+
if err := i.TaxCodes.Validate(); err != nil {
127+
errs = append(errs, err)
128+
}
129+
}
123130

124131
if i.OrderBy != "" {
125132
if err := i.OrderBy.Validate(); err != nil {

openmeter/taxcode/errors.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,23 @@ func IsTaxCodeReferencedByPlanError(err error) bool {
176176
var vi models.ValidationIssue
177177
return errors.As(err, &vi) && vi.Code() == ErrCodeTaxCodeReferencedByPlan
178178
}
179+
180+
const ErrCodeTaxCodeReferencedByAddon models.ErrorCode = "tax_code_referenced_by_addon"
181+
182+
var ErrTaxCodeReferencedByAddon = models.NewValidationIssue(
183+
ErrCodeTaxCodeReferencedByAddon,
184+
"tax code cannot be deleted as it is referenced by one or more add-ons",
185+
models.WithCriticalSeverity(),
186+
commonhttp.WithHTTPStatusCodeAttribute(http.StatusConflict),
187+
)
188+
189+
func NewTaxCodeReferencedByAddonError(taxCodeID string, addonIDs []string) error {
190+
return ErrTaxCodeReferencedByAddon.
191+
WithAttr("id", taxCodeID).
192+
WithAttr("addon_ids", addonIDs)
193+
}
194+
195+
func IsTaxCodeReferencedByAddonError(err error) bool {
196+
var vi models.ValidationIssue
197+
return errors.As(err, &vi) && vi.Code() == ErrCodeTaxCodeReferencedByAddon
198+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package hooks
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/samber/lo"
8+
9+
"github.com/openmeterio/openmeter/openmeter/productcatalog"
10+
"github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
11+
"github.com/openmeterio/openmeter/openmeter/taxcode"
12+
"github.com/openmeterio/openmeter/pkg/filter"
13+
"github.com/openmeterio/openmeter/pkg/models"
14+
"github.com/openmeterio/openmeter/pkg/pagination"
15+
)
16+
17+
type (
18+
AddonHook = models.ServiceHook[taxcode.TaxCode]
19+
NoopAddonHook = models.NoopServiceHook[taxcode.TaxCode]
20+
)
21+
22+
type AddonHookConfig struct {
23+
AddonService addon.Service
24+
}
25+
26+
func (e AddonHookConfig) Validate() error {
27+
if e.AddonService == nil {
28+
return fmt.Errorf("addon service is required")
29+
}
30+
31+
return nil
32+
}
33+
34+
var _ models.ServiceHook[taxcode.TaxCode] = (*addonHook)(nil)
35+
36+
type addonHook struct {
37+
NoopAddonHook
38+
39+
addonService addon.Service
40+
}
41+
42+
func NewAddonHook(config AddonHookConfig) (AddonHook, error) {
43+
if err := config.Validate(); err != nil {
44+
return nil, fmt.Errorf("invalid addon hook config: %w", err)
45+
}
46+
47+
return &addonHook{
48+
addonService: config.AddonService,
49+
}, nil
50+
}
51+
52+
func (e *addonHook) PreDelete(ctx context.Context, tc *taxcode.TaxCode) error {
53+
affectedAddons, err := e.addonService.ListAddons(ctx, addon.ListAddonsInput{
54+
Namespaces: []string{tc.Namespace},
55+
Status: []productcatalog.AddonStatus{
56+
productcatalog.AddonStatusActive,
57+
productcatalog.AddonStatusDraft,
58+
productcatalog.AddonStatusArchived,
59+
},
60+
TaxCodes: &filter.FilterString{
61+
In: &[]string{
62+
tc.ID,
63+
},
64+
},
65+
Page: pagination.Page{
66+
PageSize: 5,
67+
PageNumber: 1,
68+
},
69+
})
70+
if err != nil {
71+
return fmt.Errorf("failed to list add-ons: %w", err)
72+
}
73+
74+
if len(affectedAddons.Items) > 0 {
75+
addonIDs := lo.Map(affectedAddons.Items, func(item addon.Addon, _ int) string {
76+
return item.ID
77+
})
78+
return taxcode.NewTaxCodeReferencedByAddonError(tc.ID, addonIDs)
79+
}
80+
81+
return nil
82+
}

0 commit comments

Comments
 (0)