Skip to content

feat(productcatalog): eager load tax_code on plan rate cards and expo…#3945

Open
borbelyr-kong wants to merge 14 commits intomainfrom
feat/tax-codes-double-write-plan
Open

feat(productcatalog): eager load tax_code on plan rate cards and expo…#3945
borbelyr-kong wants to merge 14 commits intomainfrom
feat/tax-codes-double-write-plan

Conversation

@borbelyr-kong
Copy link
Contributor

@borbelyr-kong borbelyr-kong commented Mar 14, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added tax code support for plans and add-ons, enabling association with external tax systems
    • Introduced tax code mapping capabilities for integrations with Stripe and other platforms
    • Enhanced tax configuration persistence and retrieval with automatic backfill from dedicated storage
  • Tests

    • Added comprehensive test coverage for tax code creation, updates, and configuration management

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 14511697-f481-4b26-9d19-b78963d8b8be

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tax-codes-double-write-plan

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@borbelyr-kong borbelyr-kong added the release-note/ignore Ignore this change when generating release notes label Mar 14, 2026
@borbelyr-kong borbelyr-kong force-pushed the feat/tax-codes-v3-add-fk-keys branch from 360fb25 to 47540a1 Compare March 19, 2026 15:43
Base automatically changed from feat/tax-codes-v3-add-fk-keys to main March 19, 2026 16:28
return ratecard, nil
}

func fromTaxCodeRow(r *entdb.TaxCode) (taxcode.TaxCode, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's move this to taxcode adapter

Comment on lines +409 to +411
if m, ok := lo.Find(tc.AppMappings, func(m taxcode.TaxCodeAppMapping) bool {
return m.AppType == app.AppTypeStripe
}); ok {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add a helper for this on the TaxConfig object

Comment on lines +238 to +240
// getOrCreateTaxCode looks up a TaxCode by its Stripe app mapping. If none exists,
// it creates one with a key derived from the Stripe code.
func (s service) getOrCreateTaxCode(ctx context.Context, namespace string, stripeCode string) (taxcode.TaxCode, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this to the tax code service.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
openmeter/productcatalog/ratecard.go (1)

142-181: ⚠️ Potential issue | 🟡 Minor

Equal() doesn't compare the new TaxCode field.

The Equal() method compares all other fields in RateCardMeta but omits the new TaxCode field. If two rate cards should be considered different when their tax codes differ, this needs updating.

If the intentional behavior is to ignore TaxCode during equality checks (e.g., because it's only for display/eager loading), adding a comment would clarify that decision.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/ratecard.go` around lines 142 - 181, The Equal
method on RateCardMeta omits the new TaxCode field; update RateCardMeta.Equal to
compare TaxCode (use the same pointer-safe comparison style as
FeatureKey/FeatureID, e.g., lo.FromPtr(r.TaxCode) vs lo.FromPtr(v.TaxCode)) and
return false if they differ, or if the intended semantics are to ignore TaxCode
add a clarifying comment above RateCardMeta.Equal explaining why it is excluded;
reference the RateCardMeta.Equal method and the TaxCode field when making the
change.
openmeter/productcatalog/tax.go (1)

88-99: ⚠️ Potential issue | 🟡 Minor

Keep the cloning pattern consistent across all pointer fields.

Good catch on deep-copying TaxCodeID! Though Behavior is also a pointer field, it's assigned directly instead of being deep-copied like Stripe. For consistency and maintainability, all pointer fields should follow the same cloning pattern.

🛠️ Suggested fix
 func (c TaxConfig) Clone() TaxConfig {
-	out := TaxConfig{
-		Behavior: c.Behavior,
-	}
+	out := TaxConfig{}
+
+	if c.Behavior != nil {
+		out.Behavior = lo.ToPtr(*c.Behavior)
+	}
 
 	if c.Stripe != nil {
 		out.Stripe = lo.ToPtr(c.Stripe.Clone())
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/tax.go` around lines 88 - 99, The Clone method for
TaxConfig assigns the pointer field Behavior directly rather than deep-copying
it like Stripe and TaxCodeID; update TaxConfig.Clone to deep-copy Behavior as
well (e.g. in the same pattern used for Stripe/TaxCodeID: check if c.Behavior !=
nil and set out.Behavior to a new pointer holding a cloned or dereferenced value
using lo.ToPtr or the Behavior.Clone() method as appropriate) so all pointer
fields are cloned consistently in TaxConfig.Clone.
openmeter/productcatalog/plan/adapter/mapping.go (1)

339-341: ⚠️ Potential issue | 🟡 Minor

Error variable may be nil in this error message.

The err variable referenced here comes from the ParsePtrOrNil() call on line 300. If that succeeded but we hit this default case, err will be nil, leading to a confusing error message like invalid RateCard type foo: <nil>.

🐛 Suggested fix
 	default:
-		return nil, fmt.Errorf("invalid RateCard type %s: %w", r.Type, err)
+		return nil, fmt.Errorf("invalid RateCard type %s", r.Type)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/plan/adapter/mapping.go` around lines 339 - 341, The
default error return in the switch uses err (from ParsePtrOrNil) which may be
nil, producing messages like "invalid RateCard type foo: <nil>"; update the
default case in mapping.go to avoid appending a nil error—e.g., check if err !=
nil and return fmt.Errorf("invalid RateCard type %s: %w", r.Type, err) otherwise
return fmt.Errorf("invalid RateCard type %s", r.Type); ensure you reference the
ParsePtrOrNil result and avoid shadowing the err variable.
🧹 Nitpick comments (5)
openmeter/taxcode/service.go (1)

125-171: Consider consolidating duplicate input types.

GetTaxCodeByAppMappingInput and GetOrCreateByAppMappingInput have identical structures and validation logic. You could define a shared base type or use a single input type with a type alias if the semantic distinction matters:

type AppMappingLookupInput struct {
    Namespace string
    AppType   app.AppType
    TaxCode   string
}

type GetTaxCodeByAppMappingInput = AppMappingLookupInput
type GetOrCreateByAppMappingInput = AppMappingLookupInput

That said, keeping them separate is totally fine if you anticipate them diverging in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/taxcode/service.go` around lines 125 - 171, Replace the duplicated
structs and validation by introducing a single shared type (e.g.,
AppMappingLookupInput) containing Namespace, AppType and TaxCode, move the
Validate() implementation to that type (Validate on AppMappingLookupInput) and
then create type aliases GetTaxCodeByAppMappingInput = AppMappingLookupInput and
GetOrCreateByAppMappingInput = AppMappingLookupInput so existing code referring
to GetTaxCodeByAppMappingInput and GetOrCreateByAppMappingInput continues to
work; ensure you remove the duplicate Validate methods for the old names and
keep references to AppType.Validate() and
models.NewNillableGenericValidationError(errors.Join(...)) unchanged.
api/v3/handlers/billingprofiles/convert.go (1)

69-71: FIXME noted — consider tracking this.

The goverter ignore for TaxCodeID makes sense as a temporary measure. Just a friendly nudge to ensure this gets tracked (issue/ticket) so it doesn't get forgotten once the OpenAPI schema is updated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/billingprofiles/convert.go` around lines 69 - 71, The
goverter ignore on TaxCodeID in the ConvertBillingTaxConfigToTaxConfig
declaration is a temporary workaround; create and reference a tracking
ticket/issue for removing the "// goverter:ignore TaxCodeID" once the OpenAPI
schema is updated, then update this comment to include the issue number (e.g.,
"TODO: remove goverter:ignore TaxCodeID — tracked in ISSUE-1234") and add a
brief note in the function comment for ConvertBillingTaxConfigToTaxConfig so
reviewers can find and remove the ignore when the schema change lands.
openmeter/productcatalog/plan/service/service_test.go (1)

214-216: Consider adding a nil check for robustness.

The extraction logic assumes TaxConfig and TaxCodeID are populated. While the test setup guarantees this, a quick require.NotNil would make the test more self-documenting and catch regressions early if the tax code resolution ever fails silently.

taxConfig := draftPlanV1.Phases[0].RateCards[0].AsMeta().TaxConfig
require.NotNil(t, taxConfig, "TaxConfig should be populated after plan creation")
require.NotNil(t, taxConfig.TaxCodeID, "TaxCodeID should be resolved during plan creation")
taxCodeID := taxConfig.TaxCodeID
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/plan/service/service_test.go` around lines 214 -
216, Add explicit nil assertions before extracting TaxCodeID from draftPlanV1 to
make the test robust: retrieve taxConfig via
draftPlanV1.Phases[0].RateCards[0].AsMeta().TaxConfig, call require.NotNil(t,
taxConfig, "TaxConfig should be populated after plan creation") and
require.NotNil(t, taxConfig.TaxCodeID, "TaxCodeID should be resolved during plan
creation"), then assign taxCodeID := taxConfig.TaxCodeID; this ensures TaxConfig
and TaxCodeID are validated before use in the test.
openmeter/productcatalog/plan/service/taxcode_test.go (1)

23-38: Consider adding test coverage for UsageBasedRateCard.

The current tests comprehensively cover FlatFeeRateCard scenarios, but the production code in resolveTaxCodes has different handling for UsageBasedRateCard (BillingCadence is required and non-pointer). Adding a test case with a usage-based rate card + tax config would validate that code path.

Something like:

t.Run("UsageBasedWithStripeCode", func(t *testing.T) {
    ubp := &productcatalog.UsageBasedRateCard{
        RateCardMeta: productcatalog.RateCardMeta{
            Key:  features[0].Key,
            Name: features[0].Name,
            TaxConfig: &productcatalog.TaxConfig{
                Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_ubp_test"},
            },
            // ... price config
        },
        BillingCadence: MonthPeriod, // non-pointer
    }
    // ... test assertions
})

Based on learnings: In productcatalog.UsageBasedRateCard, the BillingCadence field is a non-pointer isodate.Period, while in productcatalog.FlatFeeRateCard, BillingCadence is a pointer type.

Also applies to: 67-337

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/plan/service/taxcode_test.go` around lines 23 - 38,
Tests only exercise FlatFeeRateCard but production resolveTaxCodes also handles
UsageBasedRateCard (with non-pointer BillingCadence); add a test covering
UsageBasedRateCard to validate that path. Create a helper similar to
newTestFlatRateCard (e.g., newTestUsageBasedRateCard) that returns a
*productcatalog.UsageBasedRateCard with RateCardMeta including a TaxConfig
(StripeTaxConfig.Code set) and a non-pointer BillingCadence (e.g., MonthPeriod),
then add a t.Run case (e.g., "UsageBasedWithStripeCode") asserting
resolveTaxCodes returns the expected stripe tax code mapping for that feature;
reference resolveTaxCodes, productcatalog.UsageBasedRateCard, RateCardMeta, and
StripeTaxConfig in the new test.
openmeter/productcatalog/plan/service/plan.go (1)

185-240: Consider extracting the rate card rebuild logic to reduce duplication.

The rate card type-switching and rebuild pattern (lines 212-236) duplicates what's already in resolveFeatures (lines 120-142, 154-176). The existing FIXME on line 37 mentions this is "a bit brittle."

This isn't blocking, but consolidating into a shared helper (e.g., rebuildRateCardWithMeta(rc RateCard, meta RateCardMeta) (RateCard, error)) would reduce duplication and make future rate card type additions easier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/plan/service/plan.go` around lines 185 - 240, The
rate-card rebuild switch in resolveTaxCodes duplicates logic from
resolveFeatures; extract that logic into a shared helper (e.g.,
rebuildRateCardWithMeta(rc productcatalog.RateCard, meta
productcatalog.RateCardMeta) (productcatalog.RateCard, error)) that inspects
rc.Type(), uses rc.GetBillingCadence() when needed, validates required billing
cadence for UsageBasedRateCardType, constructs and returns the appropriate
productcatalog.FlatFeeRateCard or productcatalog.UsageBasedRateCard with the
provided meta, and returns an error for unsupported types or missing cadence;
then call this helper from both resolveTaxCodes and resolveFeatures and keep the
existing rc.Merge(newRC) call and error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/productcatalog/ratecard.go`:
- Around line 83-84: The RateCard.Clone() method currently omits the newly added
TaxCode field so cloned RateCard instances lose the eagerly-loaded tax
reference; update the Clone() implementation(s) that copy RateCard data (look
for the Clone method on RateCard and any helper clone functions in the 93-140
region) to also copy the TaxCode pointer (preserving nil vs non-nil) into the
cloned struct, or add a clarifying comment in Clone() that omitting TaxCode is
intentional if it must remain read-only/eager-only.

In `@openmeter/productcatalog/tax_test.go`:
- Around line 156-191: Add a test case that exercises TaxConfig.Clone(): create
a TaxConfig with pointer fields (e.g., TaxCodeID), call Clone() and assert the
cloned value is equal via Equal(), then mutate the clone (change the TaxCodeID)
and assert the original is unchanged and pointers are not the same; place this
alongside the existing Equal()/MergeTaxConfigs() cases so the test suite catches
any shallow-copy regressions in TaxConfig.Clone().

In `@openmeter/taxcode/service/taxcode.go`:
- Around line 58-87: The code in GetOrCreateByAppMapping calls
s.adapter.GetTaxCodeByAppMapping and treats any error as "not found" — change
this so you only proceed to create when the error is an explicit not-found; if
err != nil and !models.IsNotFoundError(err) (or the project’s equivalent
not-found checker) return the error immediately, otherwise continue to build the
key and call s.adapter.CreateTaxCode; keep the existing concurrent-create
conflict handling using models.IsGenericConflictError and the subsequent
GetTaxCodeByAppMapping call.

---

Outside diff comments:
In `@openmeter/productcatalog/plan/adapter/mapping.go`:
- Around line 339-341: The default error return in the switch uses err (from
ParsePtrOrNil) which may be nil, producing messages like "invalid RateCard type
foo: <nil>"; update the default case in mapping.go to avoid appending a nil
error—e.g., check if err != nil and return fmt.Errorf("invalid RateCard type %s:
%w", r.Type, err) otherwise return fmt.Errorf("invalid RateCard type %s",
r.Type); ensure you reference the ParsePtrOrNil result and avoid shadowing the
err variable.

In `@openmeter/productcatalog/ratecard.go`:
- Around line 142-181: The Equal method on RateCardMeta omits the new TaxCode
field; update RateCardMeta.Equal to compare TaxCode (use the same pointer-safe
comparison style as FeatureKey/FeatureID, e.g., lo.FromPtr(r.TaxCode) vs
lo.FromPtr(v.TaxCode)) and return false if they differ, or if the intended
semantics are to ignore TaxCode add a clarifying comment above
RateCardMeta.Equal explaining why it is excluded; reference the
RateCardMeta.Equal method and the TaxCode field when making the change.

In `@openmeter/productcatalog/tax.go`:
- Around line 88-99: The Clone method for TaxConfig assigns the pointer field
Behavior directly rather than deep-copying it like Stripe and TaxCodeID; update
TaxConfig.Clone to deep-copy Behavior as well (e.g. in the same pattern used for
Stripe/TaxCodeID: check if c.Behavior != nil and set out.Behavior to a new
pointer holding a cloned or dereferenced value using lo.ToPtr or the
Behavior.Clone() method as appropriate) so all pointer fields are cloned
consistently in TaxConfig.Clone.

---

Nitpick comments:
In `@api/v3/handlers/billingprofiles/convert.go`:
- Around line 69-71: The goverter ignore on TaxCodeID in the
ConvertBillingTaxConfigToTaxConfig declaration is a temporary workaround; create
and reference a tracking ticket/issue for removing the "// goverter:ignore
TaxCodeID" once the OpenAPI schema is updated, then update this comment to
include the issue number (e.g., "TODO: remove goverter:ignore TaxCodeID —
tracked in ISSUE-1234") and add a brief note in the function comment for
ConvertBillingTaxConfigToTaxConfig so reviewers can find and remove the ignore
when the schema change lands.

In `@openmeter/productcatalog/plan/service/plan.go`:
- Around line 185-240: The rate-card rebuild switch in resolveTaxCodes
duplicates logic from resolveFeatures; extract that logic into a shared helper
(e.g., rebuildRateCardWithMeta(rc productcatalog.RateCard, meta
productcatalog.RateCardMeta) (productcatalog.RateCard, error)) that inspects
rc.Type(), uses rc.GetBillingCadence() when needed, validates required billing
cadence for UsageBasedRateCardType, constructs and returns the appropriate
productcatalog.FlatFeeRateCard or productcatalog.UsageBasedRateCard with the
provided meta, and returns an error for unsupported types or missing cadence;
then call this helper from both resolveTaxCodes and resolveFeatures and keep the
existing rc.Merge(newRC) call and error handling.

In `@openmeter/productcatalog/plan/service/service_test.go`:
- Around line 214-216: Add explicit nil assertions before extracting TaxCodeID
from draftPlanV1 to make the test robust: retrieve taxConfig via
draftPlanV1.Phases[0].RateCards[0].AsMeta().TaxConfig, call require.NotNil(t,
taxConfig, "TaxConfig should be populated after plan creation") and
require.NotNil(t, taxConfig.TaxCodeID, "TaxCodeID should be resolved during plan
creation"), then assign taxCodeID := taxConfig.TaxCodeID; this ensures TaxConfig
and TaxCodeID are validated before use in the test.

In `@openmeter/productcatalog/plan/service/taxcode_test.go`:
- Around line 23-38: Tests only exercise FlatFeeRateCard but production
resolveTaxCodes also handles UsageBasedRateCard (with non-pointer
BillingCadence); add a test covering UsageBasedRateCard to validate that path.
Create a helper similar to newTestFlatRateCard (e.g., newTestUsageBasedRateCard)
that returns a *productcatalog.UsageBasedRateCard with RateCardMeta including a
TaxConfig (StripeTaxConfig.Code set) and a non-pointer BillingCadence (e.g.,
MonthPeriod), then add a t.Run case (e.g., "UsageBasedWithStripeCode") asserting
resolveTaxCodes returns the expected stripe tax code mapping for that feature;
reference resolveTaxCodes, productcatalog.UsageBasedRateCard, RateCardMeta, and
StripeTaxConfig in the new test.

In `@openmeter/taxcode/service.go`:
- Around line 125-171: Replace the duplicated structs and validation by
introducing a single shared type (e.g., AppMappingLookupInput) containing
Namespace, AppType and TaxCode, move the Validate() implementation to that type
(Validate on AppMappingLookupInput) and then create type aliases
GetTaxCodeByAppMappingInput = AppMappingLookupInput and
GetOrCreateByAppMappingInput = AppMappingLookupInput so existing code referring
to GetTaxCodeByAppMappingInput and GetOrCreateByAppMappingInput continues to
work; ensure you remove the duplicate Validate methods for the old names and
keep references to AppType.Validate() and
models.NewNillableGenericValidationError(errors.Join(...)) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 76d2fa06-08c7-4b11-96ce-1bad47181fca

📥 Commits

Reviewing files that changed from the base of the PR and between fb0b4a2 and 7133e3c.

📒 Files selected for processing (29)
  • api/v3/handlers/billingprofiles/convert.go
  • app/common/productcatalog.go
  • cmd/billing-worker/wire.go
  • cmd/billing-worker/wire_gen.go
  • cmd/jobs/internal/wire.go
  • cmd/jobs/internal/wire_gen.go
  • cmd/server/wire_gen.go
  • openmeter/productcatalog/plan/adapter/mapping.go
  • openmeter/productcatalog/plan/adapter/phase.go
  • openmeter/productcatalog/plan/adapter/plan.go
  • openmeter/productcatalog/plan/service/plan.go
  • openmeter/productcatalog/plan/service/service.go
  • openmeter/productcatalog/plan/service/service_test.go
  • openmeter/productcatalog/plan/service/taxcode_test.go
  • openmeter/productcatalog/ratecard.go
  • openmeter/productcatalog/tax.go
  • openmeter/productcatalog/tax_test.go
  • openmeter/productcatalog/testutils/env.go
  • openmeter/server/server_test.go
  • openmeter/subscription/addon/diff/apply_test.go
  • openmeter/subscription/testutils/service.go
  • openmeter/taxcode/adapter/mapping.go
  • openmeter/taxcode/adapter/taxcode.go
  • openmeter/taxcode/repository.go
  • openmeter/taxcode/service.go
  • openmeter/taxcode/service/taxcode.go
  • openmeter/taxcode/taxcode.go
  • test/billing/subscription_suite.go
  • test/customer/testenv.go

Comment on lines +58 to +87
func (s *service) GetOrCreateByAppMapping(ctx context.Context, input taxcode.GetOrCreateByAppMappingInput) (taxcode.TaxCode, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (taxcode.TaxCode, error) {
// Try to find an existing TaxCode with this app mapping.
tc, err := s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
if err == nil {
return tc, nil
}

// Not found — create a new TaxCode.
key := fmt.Sprintf("%s_%s", input.AppType, input.TaxCode)

tc, err = s.adapter.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
Namespace: input.Namespace,
Key: key,
Name: input.TaxCode,
AppMappings: taxcode.TaxCodeAppMappings{
{AppType: input.AppType, TaxCode: input.TaxCode},
},
})
if err != nil {
// Another request may have created it concurrently.
if models.IsGenericConflictError(err) {
return s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
}

return taxcode.TaxCode{}, err
}

return tc, nil
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing "not found" check before creation.

The current logic proceeds to create a new TaxCode on any error from GetTaxCodeByAppMapping, not just "not found" errors. If there's a transient DB error or other failure, this would incorrectly attempt to create a new entity instead of propagating the error.

Consider checking for "not found" explicitly before proceeding with creation:

🛡️ Suggested fix
 func (s *service) GetOrCreateByAppMapping(ctx context.Context, input taxcode.GetOrCreateByAppMappingInput) (taxcode.TaxCode, error) {
 	return transaction.Run(ctx, s.adapter, func(ctx context.Context) (taxcode.TaxCode, error) {
 		// Try to find an existing TaxCode with this app mapping.
 		tc, err := s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
 		if err == nil {
 			return tc, nil
 		}
+
+		// Only proceed to creation if the error indicates "not found".
+		if !models.IsGenericNotFoundError(err) {
+			return taxcode.TaxCode{}, err
+		}
 
 		// Not found — create a new TaxCode.
 		key := fmt.Sprintf("%s_%s", input.AppType, input.TaxCode)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *service) GetOrCreateByAppMapping(ctx context.Context, input taxcode.GetOrCreateByAppMappingInput) (taxcode.TaxCode, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (taxcode.TaxCode, error) {
// Try to find an existing TaxCode with this app mapping.
tc, err := s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
if err == nil {
return tc, nil
}
// Not found — create a new TaxCode.
key := fmt.Sprintf("%s_%s", input.AppType, input.TaxCode)
tc, err = s.adapter.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
Namespace: input.Namespace,
Key: key,
Name: input.TaxCode,
AppMappings: taxcode.TaxCodeAppMappings{
{AppType: input.AppType, TaxCode: input.TaxCode},
},
})
if err != nil {
// Another request may have created it concurrently.
if models.IsGenericConflictError(err) {
return s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
}
return taxcode.TaxCode{}, err
}
return tc, nil
})
func (s *service) GetOrCreateByAppMapping(ctx context.Context, input taxcode.GetOrCreateByAppMappingInput) (taxcode.TaxCode, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (taxcode.TaxCode, error) {
// Try to find an existing TaxCode with this app mapping.
tc, err := s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
if err == nil {
return tc, nil
}
// Only proceed to creation if the error indicates "not found".
if !models.IsGenericNotFoundError(err) {
return taxcode.TaxCode{}, err
}
// Not found — create a new TaxCode.
key := fmt.Sprintf("%s_%s", input.AppType, input.TaxCode)
tc, err = s.adapter.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
Namespace: input.Namespace,
Key: key,
Name: input.TaxCode,
AppMappings: taxcode.TaxCodeAppMappings{
{AppType: input.AppType, TaxCode: input.TaxCode},
},
})
if err != nil {
// Another request may have created it concurrently.
if models.IsGenericConflictError(err) {
return s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input))
}
return taxcode.TaxCode{}, err
}
return tc, nil
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/taxcode/service/taxcode.go` around lines 58 - 87, The code in
GetOrCreateByAppMapping calls s.adapter.GetTaxCodeByAppMapping and treats any
error as "not found" — change this so you only proceed to create when the error
is an explicit not-found; if err != nil and !models.IsNotFoundError(err) (or the
project’s equivalent not-found checker) return the error immediately, otherwise
continue to build the key and call s.adapter.CreateTaxCode; keep the existing
concurrent-create conflict handling using models.IsGenericConflictError and the
subsequent GetTaxCodeByAppMapping call.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/productcatalog/adapter/taxconfig.go`:
- Around line 11-39: BackfillTaxConfig must also populate the TaxCodeID when
rebuilding a nil cfg: in BackfillTaxConfig, after creating cfg (cfg =
&productcatalog.TaxConfig{}) and before returning, set cfg.TaxCodeID (if it's
empty/nil) from the provided taxcode object (use tc.GetId() when tc != nil),
similar to how cfg.Behavior and cfg.Stripe are set so the backfill path
preserves the TaxCodeID field consistent with the dual-write path.

In `@openmeter/productcatalog/addon/adapter/mapping.go`:
- Around line 102-115: The FromPlanRateCardRow path never backfills legacy tax
fields from the eagerly-loaded TaxCode like the addon path does; update the
FromPlanRateCardRow handling to call r.Edges.TaxCodeOrErr(), map it via
taxcodeadapter.MapTaxCodeFromEntity, set meta.TaxCode, and then call
productcatalogadapter.BackfillTaxConfig(meta.TaxConfig, r.TaxBehavior,
meta.TaxCode) (same sequence as in the addon branch) so plan-phase rate cards
receive the same backfilled TaxConfig and TaxCode values.

In `@openmeter/productcatalog/addon/service/addon.go`:
- Around line 503-506: In UpdateAddon, move the call to s.resolveTaxCodes(ctx,
params.Namespace, params.RateCards) so it runs after the existing add-on is
loaded and after the draft-only/status validation (i.e., after the code that
enforces update allowed only for draft status), not before; this prevents
running the get-or-create write on failing/unauthorized updates and avoids
creating orphan tax codes when the update is rejected. Ensure resolveTaxCodes
remains using params.Namespace and params.RateCards and only executes once the
status check on the loaded add-on passes.

In `@openmeter/productcatalog/addon/service/taxcode_test.go`:
- Around line 527-533: The test is missing an assertion for the backfilled
TaxCodeID; update the test block that reads tc := backfillRC.AsMeta().TaxConfig
to also require/not-nil and assert the expected TaxCodeID value (e.g.,
require.NotNil or require.Equal on tc.TaxCodeID) so the dedicated-column read
path preserves TaxConfig.TaxCodeID; reference the existing variables backfillRC,
tc, and TaxConfig.TaxCodeID when adding the assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5a0996d7-7bdd-4ea3-b897-1a83eb8c662c

📥 Commits

Reviewing files that changed from the base of the PR and between 7133e3c and 8451fa5.

📒 Files selected for processing (22)
  • app/common/productcatalog.go
  • cmd/billing-worker/wire_gen.go
  • cmd/jobs/internal/wire_gen.go
  • cmd/server/wire_gen.go
  • openmeter/productcatalog/adapter/taxconfig.go
  • openmeter/productcatalog/addon/adapter/addon.go
  • openmeter/productcatalog/addon/adapter/mapping.go
  • openmeter/productcatalog/addon/service/addon.go
  • openmeter/productcatalog/addon/service/service.go
  • openmeter/productcatalog/addon/service/service_test.go
  • openmeter/productcatalog/addon/service/taxcode_test.go
  • openmeter/productcatalog/plan/adapter/mapping.go
  • openmeter/productcatalog/plan/adapter/plan.go
  • openmeter/productcatalog/plan/service/taxcode_test.go
  • openmeter/productcatalog/ratecard.go
  • openmeter/productcatalog/tax.go
  • openmeter/productcatalog/tax_test.go
  • openmeter/productcatalog/testutils/env.go
  • openmeter/subscription/addon/repo/subscriptionaddon.go
  • openmeter/subscription/testutils/service.go
  • test/billing/subscription_suite.go
  • test/customer/testenv.go
✅ Files skipped from review due to trivial changes (3)
  • cmd/billing-worker/wire_gen.go
  • cmd/jobs/internal/wire_gen.go
  • cmd/server/wire_gen.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • openmeter/productcatalog/plan/adapter/plan.go
  • openmeter/productcatalog/plan/adapter/mapping.go
  • app/common/productcatalog.go
  • openmeter/productcatalog/plan/service/taxcode_test.go

Comment on lines +102 to +115
// Map TaxCode if eagerly loaded.
taxCodeRow, err := r.Edges.TaxCodeOrErr()
if err == nil {
tc, err := taxcodeadapter.MapTaxCodeFromEntity(taxCodeRow)
if err != nil {
return nil, fmt.Errorf("invalid tax code for rate card %s: %w", r.ID, err)
}

meta.TaxCode = &tc
}

// Backfill legacy TaxConfig fields from new columns and TaxCode entity.
meta.TaxConfig = productcatalogadapter.BackfillTaxConfig(meta.TaxConfig, r.TaxBehavior, meta.TaxCode)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Please apply the same backfill to FromPlanRateCardRow.

This fixes add-on rate cards, but the expanded plan path in this file still never looks at r.Edges.TaxCode or calls BackfillTaxConfig. So plan phase rate cards that rely on tax_code_id / tax_behavior will still miss the backfilled tax data when GetAddon(..., Expand.PlanAddons=true) is used, even though addonEagerLoadActivePlans now loads TaxCode for them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/addon/adapter/mapping.go` around lines 102 - 115,
The FromPlanRateCardRow path never backfills legacy tax fields from the
eagerly-loaded TaxCode like the addon path does; update the FromPlanRateCardRow
handling to call r.Edges.TaxCodeOrErr(), map it via
taxcodeadapter.MapTaxCodeFromEntity, set meta.TaxCode, and then call
productcatalogadapter.BackfillTaxConfig(meta.TaxConfig, r.TaxBehavior,
meta.TaxCode) (same sequence as in the addon branch) so plan-phase rate cards
receive the same backfilled TaxConfig and TaxCode values.

Comment on lines +503 to +506

if err := s.resolveTaxCodes(ctx, params.Namespace, params.RateCards); err != nil {
return nil, fmt.Errorf("failed to resolve tax codes for ratecards in add-on: %w", err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate before resolving tax codes.

resolveTaxCodes does a get-or-create write. In UpdateAddon, this runs before we even load the existing add-on and enforce draft-only updates, so a rejected update still hits the DB and may leave orphan tax codes behind if the tax-code service is not enlisted in the same transaction. Moving this block below the status validation keeps the failure path side-effect free.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/addon/service/addon.go` around lines 503 - 506, In
UpdateAddon, move the call to s.resolveTaxCodes(ctx, params.Namespace,
params.RateCards) so it runs after the existing add-on is loaded and after the
draft-only/status validation (i.e., after the code that enforces update allowed
only for draft status), not before; this prevents running the get-or-create
write on failing/unauthorized updates and avoids creating orphan tax codes when
the update is rejected. Ensure resolveTaxCodes remains using params.Namespace
and params.RateCards and only executes once the status check on the loaded
add-on passes.

@borbelyr-kong borbelyr-kong marked this pull request as ready for review March 23, 2026 15:39
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
openmeter/productcatalog/addon/service/taxcode_test.go (1)

433-443: Consider asserting TaxCodeID for completeness.

The test verifies Behavior and Stripe are preserved on read-back, but doesn't check TaxCodeID. Since this test covers the full round-trip (create → get), asserting TaxCodeID would ensure the resolution path is fully validated.

🧪 Suggested assertion to add
 tc := getFirstAddonRCTaxConfig(t, fetched)
 require.NotNil(t, tc)
+
+// TaxCodeID should be present
+require.NotNil(t, tc.TaxCodeID, "TaxCodeID must be resolved and preserved")

 // Behavior should be present
 require.NotNil(t, tc.Behavior)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/productcatalog/addon/service/taxcode_test.go` around lines 433 -
443, The test checks Behavior and Stripe on the read-back tax config but omits
asserting the TaxCodeID; update the test after calling getFirstAddonRCTaxConfig
(variable tc) to assert that tc.TaxCodeID is present and equals the expected tax
code identifier used when creating the resource (e.g.,
assert.NotNil(tc.TaxCodeID) and assert.Equal(t, expectedTaxCodeID, tc.TaxCodeID)
where expectedTaxCodeID is the tax code ID variable from the create/setup step).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@openmeter/productcatalog/addon/service/taxcode_test.go`:
- Around line 433-443: The test checks Behavior and Stripe on the read-back tax
config but omits asserting the TaxCodeID; update the test after calling
getFirstAddonRCTaxConfig (variable tc) to assert that tc.TaxCodeID is present
and equals the expected tax code identifier used when creating the resource
(e.g., assert.NotNil(tc.TaxCodeID) and assert.Equal(t, expectedTaxCodeID,
tc.TaxCodeID) where expectedTaxCodeID is the tax code ID variable from the
create/setup step).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5b745e11-2fcc-4347-94b5-4db7707ddf5c

📥 Commits

Reviewing files that changed from the base of the PR and between 8451fa5 and d04acf9.

📒 Files selected for processing (5)
  • openmeter/productcatalog/adapter/taxconfig.go
  • openmeter/productcatalog/addon/service/taxcode_test.go
  • openmeter/productcatalog/plan/service/taxcode_test.go
  • openmeter/productcatalog/testutils/env.go
  • openmeter/subscription/testutils/service.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • openmeter/productcatalog/adapter/taxconfig.go
  • openmeter/productcatalog/plan/service/taxcode_test.go

@borbelyr-kong borbelyr-kong requested a review from turip March 23, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/ignore Ignore this change when generating release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants