diff --git a/api/v3/handlers/billingprofiles/convert.go b/api/v3/handlers/billingprofiles/convert.go index 23ca5830d8..1e355ccc85 100644 --- a/api/v3/handlers/billingprofiles/convert.go +++ b/api/v3/handlers/billingprofiles/convert.go @@ -69,7 +69,6 @@ var ( // goverter:ignore TaxCode ToAPIBillingTaxConfig func(config *productcatalog.TaxConfig) (*api.BillingTaxConfig, error) // goverter:map Stripe Stripe - // goverter:ignore TaxCode FromAPIBillingTaxConfig func(config *api.BillingTaxConfig) (*productcatalog.TaxConfig, error) ) diff --git a/openmeter/app/stripe/appinvoice.go b/openmeter/app/stripe/appinvoice.go index 74befb9715..0726eeef32 100644 --- a/openmeter/app/stripe/appinvoice.go +++ b/openmeter/app/stripe/appinvoice.go @@ -571,7 +571,7 @@ func getCreditStripeAddInvoiceItemParams(calculator StripeCalculator, line billi return params } -func applyTaxSettingsToInvoiceItem(add *stripe.InvoiceItemParams, taxConfig *productcatalog.TaxConfig) *stripe.InvoiceItemParams { +func applyTaxSettingsToInvoiceItem(add *stripe.InvoiceItemParams, taxConfig *billing.TaxConfig) *stripe.InvoiceItemParams { if taxConfig != nil && !lo.IsEmpty(taxConfig) { if taxConfig.Behavior != nil { add.TaxBehavior = getStripeTaxBehavior(taxConfig.Behavior) diff --git a/openmeter/billing/adapter/gatheringlines.go b/openmeter/billing/adapter/gatheringlines.go index eb19f40be3..c597889e4b 100644 --- a/openmeter/billing/adapter/gatheringlines.go +++ b/openmeter/billing/adapter/gatheringlines.go @@ -221,7 +221,7 @@ func (a *adapter) updateGatheringLines(ctx context.Context, lines billing.Gather } if line.TaxConfig != nil { - create = create.SetTaxConfig(*line.TaxConfig). + create = create.SetNillableTaxConfig(billing.FromProductCatalog(line.TaxConfig)). SetNillableTaxCodeID(line.TaxConfig.TaxCodeID). SetNillableTaxBehavior(line.TaxConfig.Behavior) } @@ -310,7 +310,7 @@ func (a *adapter) mapGatheringInvoiceLineFromDB(schemaLevel int, dbLine *db.Bill Currency: dbLine.Currency, TaxConfig: productcatalog.BackfillTaxConfig( - lo.EmptyableToPtr(dbLine.TaxConfig), + lo.EmptyableToPtr(dbLine.TaxConfig).ToProductCatalog(), dbLine.TaxBehavior, taxCodeFromInvoiceLineEdge(dbLine), ), diff --git a/openmeter/billing/adapter/stdinvoicelinemapper.go b/openmeter/billing/adapter/stdinvoicelinemapper.go index b70427a7bb..a8fb9c381b 100644 --- a/openmeter/billing/adapter/stdinvoicelinemapper.go +++ b/openmeter/billing/adapter/stdinvoicelinemapper.go @@ -318,28 +318,32 @@ func taxCodeFromInvoiceLineEdge(dbLine *db.BillingInvoiceLine) *taxcode.TaxCode // TODO[later]: change the billing-facing types to expose TaxCodeSnapshot and TaxCodeReference // fields explicitly so it is obvious what is the immutable invoice snapshot and what is the live // reference to the tax entity. -func backfillTaxConfigReferences(snapshottedTaxConfig *productcatalog.TaxConfig, persistedTaxBehavior *productcatalog.TaxBehavior, resolvedTaxCode *taxcode.TaxCode) *productcatalog.TaxConfig { - backfilledTaxConfig := productcatalog.BackfillTaxConfig(snapshottedTaxConfig, persistedTaxBehavior, resolvedTaxCode) +func backfillTaxConfigReferences(snapshottedTaxConfig *billing.TaxConfig, persistedTaxBehavior *productcatalog.TaxBehavior, resolvedTaxCode *taxcode.TaxCode) *billing.TaxConfig { + if snapshottedTaxConfig == nil { + return billing.FromProductCatalog(productcatalog.BackfillTaxConfig(nil, persistedTaxBehavior, resolvedTaxCode)) + } + + backfilledTaxConfig := productcatalog.BackfillTaxConfig(snapshottedTaxConfig.ToProductCatalog(), persistedTaxBehavior, resolvedTaxCode) if backfilledTaxConfig == nil || resolvedTaxCode == nil { - return backfilledTaxConfig + return billing.FromProductCatalog(backfilledTaxConfig) } if backfilledTaxConfig.TaxCodeID != nil && *backfilledTaxConfig.TaxCodeID != resolvedTaxCode.ID { - return backfilledTaxConfig + return billing.FromProductCatalog(backfilledTaxConfig) } if backfilledTaxConfig.Stripe != nil { mapping, ok := resolvedTaxCode.GetAppMapping(app.AppTypeStripe) if !ok || mapping.TaxCode != backfilledTaxConfig.Stripe.Code { - return backfilledTaxConfig + return billing.FromProductCatalog(backfilledTaxConfig) } } - cloned := backfilledTaxConfig.Clone() - cloned.TaxCode = resolvedTaxCode + result := billing.FromProductCatalog(backfilledTaxConfig) + result.TaxCode = resolvedTaxCode - return &cloned + return result } func (a *adapter) mapStandardInvoiceDetailedLineAmountDiscountFromDB(dbDiscount *db.BillingStandardInvoiceDetailedLineAmountDiscount) (billing.AmountLineDiscountManaged, error) { diff --git a/openmeter/billing/gatheringinvoice.go b/openmeter/billing/gatheringinvoice.go index cf620d0bab..470e4fa42c 100644 --- a/openmeter/billing/gatheringinvoice.go +++ b/openmeter/billing/gatheringinvoice.go @@ -717,9 +717,9 @@ func (g GatheringLine) AsNewStandardLine(invoiceID string) (*StandardLine, error return nil, fmt.Errorf("cloning annotations: %w", err) } - var taxConfig *productcatalog.TaxConfig + var taxConfig *TaxConfig if g.TaxConfig != nil { - taxConfig = lo.ToPtr(g.TaxConfig.Clone()) + taxConfig = FromProductCatalog(lo.ToPtr(g.TaxConfig.Clone())) } var subscription *SubscriptionReference diff --git a/openmeter/billing/httpdriver/invoiceline.go b/openmeter/billing/httpdriver/invoiceline.go index 72d5c705c0..6972e6536e 100644 --- a/openmeter/billing/httpdriver/invoiceline.go +++ b/openmeter/billing/httpdriver/invoiceline.go @@ -160,7 +160,7 @@ func mapCreateLineToEntity(line api.InvoicePendingLineCreate, ns string) (*billi }, InvoiceAt: line.InvoiceAt, - TaxConfig: rateCardParsed.TaxConfig, + TaxConfig: billing.FromProductCatalog(rateCardParsed.TaxConfig), RateCardDiscounts: rateCardParsed.Discounts, }, UsageBased: &billing.UsageBasedLine{ @@ -324,7 +324,7 @@ func mapInvoiceLineToAPI(line *billing.StandardLine) (api.InvoiceLine, error) { return api.InvoiceLine{}, fmt.Errorf("failed to map price: %w", err) } - children, err := mapDetailedLinesToAPI(line.DetailedLines, line.InvoiceAt, line.TaxConfig) + children, err := mapDetailedLinesToAPI(line.DetailedLines, line.InvoiceAt, line.TaxConfig.ToProductCatalog()) if err != nil { return api.InvoiceLine{}, fmt.Errorf("failed to map children: %w", err) } @@ -362,7 +362,7 @@ func mapInvoiceLineToAPI(line *billing.StandardLine) (api.InvoiceLine, error) { To: line.Period.To, }, - TaxConfig: mapTaxConfigToAPI(line.TaxConfig), + TaxConfig: mapTaxConfigToAPI(line.TaxConfig.ToProductCatalog()), FeatureKey: lo.EmptyableToPtr(line.UsageBased.FeatureKey), MeteredQuantity: decimalPtrToStringPtrIfNotEqual(line.UsageBased.MeteredQuantity, line.UsageBased.Quantity), @@ -373,7 +373,7 @@ func mapInvoiceLineToAPI(line *billing.StandardLine) (api.InvoiceLine, error) { Price: lo.ToPtr(price), RateCard: &api.InvoiceUsageBasedRateCard{ - TaxConfig: mapTaxConfigToAPI(line.TaxConfig), + TaxConfig: mapTaxConfigToAPI(line.TaxConfig.ToProductCatalog()), Price: lo.ToPtr(price), FeatureKey: lo.EmptyableToPtr(line.UsageBased.FeatureKey), }, @@ -634,7 +634,7 @@ func mapSimulationLineToEntity(line api.InvoiceSimulationLine) (*billing.Standar }, InvoiceAt: line.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration), - TaxConfig: rateCardParsed.TaxConfig, + TaxConfig: billing.FromProductCatalog(rateCardParsed.TaxConfig), RateCardDiscounts: rateCardParsed.Discounts, }, UsageBased: &billing.UsageBasedLine{ @@ -679,7 +679,7 @@ func standardLineFromInvoiceLineReplaceUpdate(line api.InvoiceLineReplaceUpdate, }, InvoiceAt: line.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration), - TaxConfig: rateCardParsed.TaxConfig, + TaxConfig: billing.FromProductCatalog(rateCardParsed.TaxConfig), RateCardDiscounts: rateCardParsed.Discounts, }, UsageBased: &billing.UsageBasedLine{ @@ -760,7 +760,7 @@ func mergeStandardLineFromInvoiceLineReplaceUpdate(existing *billing.StandardLin existing.Period.To = line.Period.To.Truncate(streaming.MinimumWindowSizeDuration) existing.InvoiceAt = line.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration) - existing.TaxConfig = rateCardParsed.TaxConfig + existing.TaxConfig = billing.FromProductCatalog(rateCardParsed.TaxConfig) existing.UsageBased.Price = rateCardParsed.Price existing.UsageBased.FeatureKey = rateCardParsed.FeatureKey diff --git a/openmeter/billing/service/invoicecalc/taxconfig.go b/openmeter/billing/service/invoicecalc/taxconfig.go index 3d8e668e15..fdf71376c3 100644 --- a/openmeter/billing/service/invoicecalc/taxconfig.go +++ b/openmeter/billing/service/invoicecalc/taxconfig.go @@ -4,7 +4,6 @@ import ( "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" - "github.com/openmeterio/openmeter/openmeter/productcatalog" ) // SnapshotTaxConfigIntoLines merges the invoice's DefaultTaxConfig into each line and @@ -16,7 +15,7 @@ func SnapshotTaxConfigIntoLines(invoice *billing.StandardInvoice, deps StandardI } for _, line := range invoice.Lines.OrEmpty() { - line.TaxConfig = productcatalog.MergeTaxConfigs(invoice.Workflow.Config.Invoicing.DefaultTaxConfig, line.TaxConfig) + line.TaxConfig = billing.MergeTaxConfigs(billing.FromProductCatalog(invoice.Workflow.Config.Invoicing.DefaultTaxConfig), line.TaxConfig) if line.TaxConfig == nil || line.TaxConfig.Stripe == nil { continue diff --git a/openmeter/billing/service/invoicecalc/taxconfig_test.go b/openmeter/billing/service/invoicecalc/taxconfig_test.go index 99d646c6c4..4c7486a1d4 100644 --- a/openmeter/billing/service/invoicecalc/taxconfig_test.go +++ b/openmeter/billing/service/invoicecalc/taxconfig_test.go @@ -14,6 +14,71 @@ import ( "github.com/openmeterio/openmeter/pkg/models" ) +// TaxConfigEqualDetectsTaxCode is the regression guard for the fix: billing.TaxConfig.Equal +// now includes the resolved TaxCode entity. Two configs that are identical except for TaxCode +// (one nil, one stamped) must compare as NOT equal, so the adapter diff guard re-upserts the line +// and the snapshot is persisted to the tax_config JSONB column. +func TestTaxConfigEqualDetectsTaxCode(t *testing.T) { + tc1 := taxcode.TaxCode{ + NamespacedID: models.NamespacedID{Namespace: "ns", ID: "tc-1"}, + AppMappings: taxcode.TaxCodeAppMappings{ + {AppType: app.AppTypeStripe, TaxCode: "txcd_10000000"}, + }, + } + + // Persisted state: TaxCodeID set, but the TaxCode entity snapshot is absent. + persistedState := &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), + TaxCodeID: lo.ToPtr("tc-1"), + }, + TaxCode: nil, + } + + // In-memory state after SnapshotTaxConfigIntoLines stamps the entity. Identical except TaxCode. + expectedState := &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), + TaxCodeID: lo.ToPtr("tc-1"), + }, + TaxCode: &tc1, + } + + assert.False(t, persistedState.Equal(expectedState), + "Equal must detect the stamped TaxCode so the line is re-upserted and the snapshot persisted") + + // Same-ID sub-case: two configs whose TaxCode shares the same ID but differs in other fields + // (e.g. AppMappings) must compare as equal — equality is ID-only. + tc2 := taxcode.TaxCode{ + NamespacedID: models.NamespacedID{Namespace: "ns", ID: "tc-1"}, + AppMappings: taxcode.TaxCodeAppMappings{ + {AppType: app.AppTypeStripe, TaxCode: "txcd_20000000"}, + }, + } + + leftConfig := &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), + TaxCodeID: lo.ToPtr("tc-1"), + }, + TaxCode: &tc1, + } + rightConfig := &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), + TaxCodeID: lo.ToPtr("tc-1"), + }, + TaxCode: &tc2, + } + + assert.True(t, leftConfig.Equal(rightConfig), + "Equal uses ID-only comparison: same TaxCode.ID means equal regardless of other TaxCode fields") +} + func TestSnapshotTaxConfigIntoLines(t *testing.T) { tc1 := taxcode.TaxCode{ NamespacedID: models.NamespacedID{Namespace: "ns", ID: "tc-1"}, @@ -47,7 +112,7 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { newLine := func(tc *productcatalog.TaxConfig) *billing.StandardLine { return &billing.StandardLine{ StandardLineBase: billing.StandardLineBase{ - TaxConfig: tc, + TaxConfig: billing.FromProductCatalog(tc), }, } } @@ -56,7 +121,7 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { name string invoice billing.StandardInvoice deps StandardInvoiceCalculatorDependencies - wantTC *productcatalog.TaxConfig + wantTC *billing.TaxConfig wantNoErr bool }{ { @@ -69,7 +134,7 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { }), ), deps: StandardInvoiceCalculatorDependencies{TaxCodes: TaxCodes{"txcd_10000000": tc1}}, - wantTC: &productcatalog.TaxConfig{Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}}, + wantTC: &billing.TaxConfig{TaxConfig: productcatalog.TaxConfig{Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}}}, wantNoErr: true, }, { @@ -82,10 +147,12 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { }), ), deps: StandardInvoiceCalculatorDependencies{TaxCodes: TaxCodes{"txcd_10000000": tc1}}, - wantTC: &productcatalog.TaxConfig{ - Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, - TaxCodeID: lo.ToPtr("tc-1"), - TaxCode: &tc1, + wantTC: &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + TaxCodeID: lo.ToPtr("tc-1"), + }, + TaxCode: &tc1, }, wantNoErr: true, }, @@ -100,10 +167,12 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { }), ), deps: StandardInvoiceCalculatorDependencies{TaxCodes: TaxCodes{"txcd_10000000": tc1}}, - wantTC: &productcatalog.TaxConfig{ - Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, - TaxCodeID: lo.ToPtr("already-set"), - TaxCode: &tc1, + wantTC: &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + TaxCodeID: lo.ToPtr("already-set"), + }, + TaxCode: &tc1, }, wantNoErr: true, }, @@ -117,8 +186,10 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { }), ), deps: StandardInvoiceCalculatorDependencies{TaxCodes: TaxCodes{}}, - wantTC: &productcatalog.TaxConfig{ - Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + wantTC: &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + }, }, wantNoErr: true, }, @@ -133,11 +204,13 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { newLine(nil), ), deps: StandardInvoiceCalculatorDependencies{TaxCodes: TaxCodes{"txcd_10000000": tc1}}, - wantTC: &productcatalog.TaxConfig{ - Behavior: lo.ToPtr(productcatalog.InclusiveTaxBehavior), - Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, - TaxCodeID: lo.ToPtr("tc-1"), - TaxCode: &tc1, + wantTC: &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Behavior: lo.ToPtr(productcatalog.InclusiveTaxBehavior), + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_10000000"}, + TaxCodeID: lo.ToPtr("tc-1"), + }, + TaxCode: &tc1, }, wantNoErr: true, }, @@ -156,10 +229,12 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { "txcd_10000000": tc1, "txcd_20000000": tc2, }}, - wantTC: &productcatalog.TaxConfig{ - Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_20000000"}, - TaxCodeID: lo.ToPtr("tc-2"), - TaxCode: &tc2, + wantTC: &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_20000000"}, + TaxCodeID: lo.ToPtr("tc-2"), + }, + TaxCode: &tc2, }, wantNoErr: true, }, @@ -173,8 +248,10 @@ func TestSnapshotTaxConfigIntoLines(t *testing.T) { }), ), deps: StandardInvoiceCalculatorDependencies{TaxCodes: TaxCodes{"txcd_10000000": tc1}}, - wantTC: &productcatalog.TaxConfig{ - Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), + wantTC: &billing.TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior), + }, }, wantNoErr: true, }, diff --git a/openmeter/billing/stdinvoice.go b/openmeter/billing/stdinvoice.go index 9535aff6cf..5ff5c25103 100644 --- a/openmeter/billing/stdinvoice.go +++ b/openmeter/billing/stdinvoice.go @@ -13,7 +13,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/models/externalid" "github.com/openmeterio/openmeter/openmeter/billing/models/totals" "github.com/openmeterio/openmeter/openmeter/customer" - "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/expand" @@ -450,7 +449,7 @@ func (i *StandardInvoice) getLeafLines() DetailedLines { type DetailedLineWithResolvedTaxConfig struct { DetailedLine - TaxConfig *productcatalog.TaxConfig + TaxConfig *TaxConfig } // GetLeafLinesWithResolvedTaxConfig returns invoice leaf lines together with the effective tax @@ -459,7 +458,7 @@ func (i *StandardInvoice) GetLeafLinesWithResolvedTaxConfig() []DetailedLineWith out := make([]DetailedLineWithResolvedTaxConfig, 0) for _, parentLine := range i.Lines.OrEmpty() { - taxConfig := productcatalog.MergeTaxConfigs(i.Workflow.Config.Invoicing.DefaultTaxConfig, parentLine.TaxConfig) + taxConfig := MergeTaxConfigs(FromProductCatalog(i.Workflow.Config.Invoicing.DefaultTaxConfig), parentLine.TaxConfig) for _, line := range parentLine.DetailedLines { out = append(out, DetailedLineWithResolvedTaxConfig{ DetailedLine: line, diff --git a/openmeter/billing/stdinvoiceline.go b/openmeter/billing/stdinvoiceline.go index 7b0ee290f3..5204b8abcc 100644 --- a/openmeter/billing/stdinvoiceline.go +++ b/openmeter/billing/stdinvoiceline.go @@ -45,9 +45,9 @@ type StandardLineBase struct { ChildUniqueReferenceID *string `json:"childUniqueReferenceID,omitempty"` - TaxConfig *productcatalog.TaxConfig `json:"taxOverrides,omitempty"` - RateCardDiscounts Discounts `json:"rateCardDiscounts,omitempty"` - CreditsApplied CreditsApplied `json:"creditsApplied,omitempty"` + TaxConfig *TaxConfig `json:"taxOverrides,omitempty"` + RateCardDiscounts Discounts `json:"rateCardDiscounts,omitempty"` + CreditsApplied CreditsApplied `json:"creditsApplied,omitempty"` ExternalIDs externalid.LineExternalIDs `json:"externalIDs,omitempty"` Subscription *SubscriptionReference `json:"subscription,omitempty"` @@ -406,7 +406,7 @@ func (i StandardLine) ToGatheringLineBase() (GatheringLineBase, error) { InvoiceAt: i.InvoiceAt, Price: lo.FromPtr(i.UsageBased.Price), FeatureKey: i.UsageBased.FeatureKey, - TaxConfig: i.TaxConfig, + TaxConfig: i.TaxConfig.ToProductCatalog(), RateCardDiscounts: i.RateCardDiscounts, ChildUniqueReferenceID: i.ChildUniqueReferenceID, Subscription: i.Subscription, diff --git a/openmeter/billing/taxconfig.go b/openmeter/billing/taxconfig.go new file mode 100644 index 0000000000..62c6b8c56f --- /dev/null +++ b/openmeter/billing/taxconfig.go @@ -0,0 +1,138 @@ +package billing + +import ( + "errors" + "fmt" + "slices" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/models" +) + +// TaxConfig is the billing-layer tax configuration. It extends productcatalog.TaxConfig with +// TaxCode, the resolved entity snapshot stamped at invoice snapshot time. +type TaxConfig struct { + productcatalog.TaxConfig + // TaxCode is the resolved TaxCode entity, stamped at invoice snapshot time. + TaxCode *taxcode.TaxCode `json:"tax_code,omitempty"` +} + +// FromProductCatalog promotes a productcatalog.TaxConfig into a billing.TaxConfig. +// TaxCode is left nil; stamp it after resolving the entity. +func FromProductCatalog(c *productcatalog.TaxConfig) *TaxConfig { + if c == nil { + return nil + } + + return &TaxConfig{ + TaxConfig: c.Clone(), + } +} + +// ToProductCatalog strips the billing-only TaxCode field and returns the intent-level config. +func (c *TaxConfig) ToProductCatalog() *productcatalog.TaxConfig { + if c == nil { + return nil + } + + return lo.ToPtr(c.TaxConfig.Clone()) +} + +func (c *TaxConfig) Equal(v *TaxConfig) bool { + if c == nil && v == nil { + return true + } + + if c == nil || v == nil { + return false + } + + if (c.TaxCode != nil && v.TaxCode == nil) || (c.TaxCode == nil && v.TaxCode != nil) { + return false + } + + if c.TaxCode != nil && c.TaxCode.ID != v.TaxCode.ID { + return false + } + + return c.TaxConfig.Equal(&v.TaxConfig) +} + +func (c *TaxConfig) Validate() error { + if c == nil { + return nil + } + + var errs []error + + if c.Behavior != nil { + if err := c.Behavior.Validate(); err != nil { + errs = append(errs, err) + } + } + + if c.Stripe != nil { + if err := c.Stripe.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invalid stripe config: %w", err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (c TaxConfig) Clone() TaxConfig { + out := TaxConfig{ + TaxConfig: c.TaxConfig.Clone(), + } + + if c.TaxCode != nil { + tc := *c.TaxCode + tc.AppMappings = slices.Clone(c.TaxCode.AppMappings) + if c.TaxCode.Description != nil { + tc.Description = lo.ToPtr(*c.TaxCode.Description) + } + out.TaxCode = &tc + } + + return out +} + +// MergeTaxConfigs merges two billing TaxConfigs with overrides taking precedence. +// +// Stripe and TaxCodeID are two encodings of the same intent-level tax-code identity, so they +// merge as a unit: a config that overrides only the Stripe code must not inherit the base's +// (different) TaxCodeID, which would leave the result pointing at two different tax entities. +// +// TaxCode (resolved entity) is intentionally excluded: merge operates on intent-level configs, +// not snapshotted invoice lines. +func MergeTaxConfigs(base, overrides *TaxConfig) *TaxConfig { + if base != nil && overrides != nil { + stripe, taxCodeID := base.Stripe, base.TaxCodeID + if overrides.Stripe != nil || overrides.TaxCodeID != nil { + stripe, taxCodeID = overrides.Stripe, overrides.TaxCodeID + } + + return &TaxConfig{ + TaxConfig: productcatalog.TaxConfig{ + Behavior: lo.CoalesceOrEmpty(overrides.Behavior, base.Behavior), + Stripe: stripe, + TaxCodeID: taxCodeID, + }, + } + } + + if overrides != nil { + c := overrides.Clone() + return &c + } + + if base != nil { + c := base.Clone() + return &c + } + + return nil +} diff --git a/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go b/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go index 26d52c8d14..1c460e76cd 100644 --- a/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go +++ b/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go @@ -7358,7 +7358,7 @@ func (s *CreditThenInvoiceTestSuite) assertStandardLineTaxConfigs(lines []*billi s.Require().NotEmpty(lines) for _, line := range lines { s.Require().NotNil(line.TaxConfig) - s.True(expected.Equal(line.TaxConfig), "line %s tax config: expected %+v, got %+v", line.ID, expected, line.TaxConfig) + s.True(expected.Equal(line.TaxConfig.ToProductCatalog()), "line %s tax config: expected %+v, got %+v", line.ID, expected, line.TaxConfig) } } diff --git a/openmeter/ent/db/billinginvoiceline.go b/openmeter/ent/db/billinginvoiceline.go index eb2cab0f50..2b54752609 100644 --- a/openmeter/ent/db/billinginvoiceline.go +++ b/openmeter/ent/db/billinginvoiceline.go @@ -56,7 +56,7 @@ type BillingInvoiceLine struct { // Currency holds the value of the "currency" field. Currency currencyx.Code `json:"currency,omitempty"` // TaxConfig holds the value of the "tax_config" field. - TaxConfig productcatalog.TaxConfig `json:"tax_config,omitempty"` + TaxConfig billing.TaxConfig `json:"tax_config,omitempty"` // TaxCodeID holds the value of the "tax_code_id" field. TaxCodeID *string `json:"tax_code_id,omitempty"` // TaxBehavior holds the value of the "tax_behavior" field. diff --git a/openmeter/ent/db/billinginvoiceline_create.go b/openmeter/ent/db/billinginvoiceline_create.go index 04299d2b73..a06644a096 100644 --- a/openmeter/ent/db/billinginvoiceline_create.go +++ b/openmeter/ent/db/billinginvoiceline_create.go @@ -133,13 +133,13 @@ func (_c *BillingInvoiceLineCreate) SetCurrency(v currencyx.Code) *BillingInvoic } // SetTaxConfig sets the "tax_config" field. -func (_c *BillingInvoiceLineCreate) SetTaxConfig(v productcatalog.TaxConfig) *BillingInvoiceLineCreate { +func (_c *BillingInvoiceLineCreate) SetTaxConfig(v billing.TaxConfig) *BillingInvoiceLineCreate { _c.mutation.SetTaxConfig(v) return _c } // SetNillableTaxConfig sets the "tax_config" field if the given value is not nil. -func (_c *BillingInvoiceLineCreate) SetNillableTaxConfig(v *productcatalog.TaxConfig) *BillingInvoiceLineCreate { +func (_c *BillingInvoiceLineCreate) SetNillableTaxConfig(v *billing.TaxConfig) *BillingInvoiceLineCreate { if v != nil { _c.SetTaxConfig(*v) } @@ -1537,7 +1537,7 @@ func (u *BillingInvoiceLineUpsert) ClearDescription() *BillingInvoiceLineUpsert } // SetTaxConfig sets the "tax_config" field. -func (u *BillingInvoiceLineUpsert) SetTaxConfig(v productcatalog.TaxConfig) *BillingInvoiceLineUpsert { +func (u *BillingInvoiceLineUpsert) SetTaxConfig(v billing.TaxConfig) *BillingInvoiceLineUpsert { u.Set(billinginvoiceline.FieldTaxConfig, v) return u } @@ -2213,7 +2213,7 @@ func (u *BillingInvoiceLineUpsertOne) ClearDescription() *BillingInvoiceLineUpse } // SetTaxConfig sets the "tax_config" field. -func (u *BillingInvoiceLineUpsertOne) SetTaxConfig(v productcatalog.TaxConfig) *BillingInvoiceLineUpsertOne { +func (u *BillingInvoiceLineUpsertOne) SetTaxConfig(v billing.TaxConfig) *BillingInvoiceLineUpsertOne { return u.Update(func(s *BillingInvoiceLineUpsert) { s.SetTaxConfig(v) }) @@ -3143,7 +3143,7 @@ func (u *BillingInvoiceLineUpsertBulk) ClearDescription() *BillingInvoiceLineUps } // SetTaxConfig sets the "tax_config" field. -func (u *BillingInvoiceLineUpsertBulk) SetTaxConfig(v productcatalog.TaxConfig) *BillingInvoiceLineUpsertBulk { +func (u *BillingInvoiceLineUpsertBulk) SetTaxConfig(v billing.TaxConfig) *BillingInvoiceLineUpsertBulk { return u.Update(func(s *BillingInvoiceLineUpsert) { s.SetTaxConfig(v) }) diff --git a/openmeter/ent/db/billinginvoiceline_update.go b/openmeter/ent/db/billinginvoiceline_update.go index 962550b3df..003512c6e8 100644 --- a/openmeter/ent/db/billinginvoiceline_update.go +++ b/openmeter/ent/db/billinginvoiceline_update.go @@ -135,13 +135,13 @@ func (_u *BillingInvoiceLineUpdate) ClearDescription() *BillingInvoiceLineUpdate } // SetTaxConfig sets the "tax_config" field. -func (_u *BillingInvoiceLineUpdate) SetTaxConfig(v productcatalog.TaxConfig) *BillingInvoiceLineUpdate { +func (_u *BillingInvoiceLineUpdate) SetTaxConfig(v billing.TaxConfig) *BillingInvoiceLineUpdate { _u.mutation.SetTaxConfig(v) return _u } // SetNillableTaxConfig sets the "tax_config" field if the given value is not nil. -func (_u *BillingInvoiceLineUpdate) SetNillableTaxConfig(v *productcatalog.TaxConfig) *BillingInvoiceLineUpdate { +func (_u *BillingInvoiceLineUpdate) SetNillableTaxConfig(v *billing.TaxConfig) *BillingInvoiceLineUpdate { if v != nil { _u.SetTaxConfig(*v) } @@ -2085,13 +2085,13 @@ func (_u *BillingInvoiceLineUpdateOne) ClearDescription() *BillingInvoiceLineUpd } // SetTaxConfig sets the "tax_config" field. -func (_u *BillingInvoiceLineUpdateOne) SetTaxConfig(v productcatalog.TaxConfig) *BillingInvoiceLineUpdateOne { +func (_u *BillingInvoiceLineUpdateOne) SetTaxConfig(v billing.TaxConfig) *BillingInvoiceLineUpdateOne { _u.mutation.SetTaxConfig(v) return _u } // SetNillableTaxConfig sets the "tax_config" field if the given value is not nil. -func (_u *BillingInvoiceLineUpdateOne) SetNillableTaxConfig(v *productcatalog.TaxConfig) *BillingInvoiceLineUpdateOne { +func (_u *BillingInvoiceLineUpdateOne) SetNillableTaxConfig(v *billing.TaxConfig) *BillingInvoiceLineUpdateOne { if v != nil { _u.SetTaxConfig(*v) } diff --git a/openmeter/ent/db/entmixinaccessor.go b/openmeter/ent/db/entmixinaccessor.go index 95d0c25347..aca1d6d3f4 100644 --- a/openmeter/ent/db/entmixinaccessor.go +++ b/openmeter/ent/db/entmixinaccessor.go @@ -445,7 +445,7 @@ func (e *BillingInvoiceLine) GetCurrency() currencyx.Code { return e.Currency } -func (e *BillingInvoiceLine) GetTaxConfig() productcatalog.TaxConfig { +func (e *BillingInvoiceLine) GetTaxConfig() billing.TaxConfig { return e.TaxConfig } diff --git a/openmeter/ent/db/mutation.go b/openmeter/ent/db/mutation.go index 03182c6a81..957900f60d 100644 --- a/openmeter/ent/db/mutation.go +++ b/openmeter/ent/db/mutation.go @@ -17321,7 +17321,7 @@ type BillingInvoiceLineMutation struct { name *string description *string currency *currencyx.Code - tax_config *productcatalog.TaxConfig + tax_config *billing.TaxConfig tax_behavior *productcatalog.TaxBehavior amount *alpacadecimal.Decimal taxes_total *alpacadecimal.Decimal @@ -17877,12 +17877,12 @@ func (m *BillingInvoiceLineMutation) ResetCurrency() { } // SetTaxConfig sets the "tax_config" field. -func (m *BillingInvoiceLineMutation) SetTaxConfig(pc productcatalog.TaxConfig) { - m.tax_config = &pc +func (m *BillingInvoiceLineMutation) SetTaxConfig(bc billing.TaxConfig) { + m.tax_config = &bc } // TaxConfig returns the value of the "tax_config" field in the mutation. -func (m *BillingInvoiceLineMutation) TaxConfig() (r productcatalog.TaxConfig, exists bool) { +func (m *BillingInvoiceLineMutation) TaxConfig() (r billing.TaxConfig, exists bool) { v := m.tax_config if v == nil { return @@ -17893,7 +17893,7 @@ func (m *BillingInvoiceLineMutation) TaxConfig() (r productcatalog.TaxConfig, ex // OldTaxConfig returns the old "tax_config" field's value of the BillingInvoiceLine entity. // If the BillingInvoiceLine object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *BillingInvoiceLineMutation) OldTaxConfig(ctx context.Context) (v productcatalog.TaxConfig, err error) { +func (m *BillingInvoiceLineMutation) OldTaxConfig(ctx context.Context) (v billing.TaxConfig, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldTaxConfig is only allowed on UpdateOne operations") } @@ -20493,7 +20493,7 @@ func (m *BillingInvoiceLineMutation) SetField(name string, value ent.Value) erro m.SetCurrency(v) return nil case billinginvoiceline.FieldTaxConfig: - v, ok := value.(productcatalog.TaxConfig) + v, ok := value.(billing.TaxConfig) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } diff --git a/openmeter/ent/db/setorclear.go b/openmeter/ent/db/setorclear.go index 2d1e0e11cc..2e7034f49d 100644 --- a/openmeter/ent/db/setorclear.go +++ b/openmeter/ent/db/setorclear.go @@ -1153,14 +1153,14 @@ func (u *BillingInvoiceLineUpdateOne) SetOrClearDescription(value *string) *Bill return u.SetDescription(*value) } -func (u *BillingInvoiceLineUpdate) SetOrClearTaxConfig(value *productcatalog.TaxConfig) *BillingInvoiceLineUpdate { +func (u *BillingInvoiceLineUpdate) SetOrClearTaxConfig(value *billing.TaxConfig) *BillingInvoiceLineUpdate { if value == nil { return u.ClearTaxConfig() } return u.SetTaxConfig(*value) } -func (u *BillingInvoiceLineUpdateOne) SetOrClearTaxConfig(value *productcatalog.TaxConfig) *BillingInvoiceLineUpdateOne { +func (u *BillingInvoiceLineUpdateOne) SetOrClearTaxConfig(value *billing.TaxConfig) *BillingInvoiceLineUpdateOne { if value == nil { return u.ClearTaxConfig() } diff --git a/openmeter/ent/schema/billing.go b/openmeter/ent/schema/billing.go index 262077704f..5fec400465 100644 --- a/openmeter/ent/schema/billing.go +++ b/openmeter/ent/schema/billing.go @@ -292,7 +292,7 @@ func (InvoiceLineBaseMixin) Fields() []ent.Field { dialect.Postgres: "varchar(3)", }), - field.JSON("tax_config", productcatalog.TaxConfig{}). + field.JSON("tax_config", billing.TaxConfig{}). SchemaType(map[string]string{ dialect.Postgres: "jsonb", }). diff --git a/openmeter/productcatalog/tax.go b/openmeter/productcatalog/tax.go index b9f4bb443f..03c480cc72 100644 --- a/openmeter/productcatalog/tax.go +++ b/openmeter/productcatalog/tax.go @@ -39,9 +39,6 @@ type TaxConfig struct { Behavior *TaxBehavior `json:"behavior,omitempty"` Stripe *StripeTaxConfig `json:"stripe,omitempty"` TaxCodeID *string `json:"tax_code_id,omitempty"` - // TaxCode is the resolved TaxCode entity, stamped at invoice snapshot time. - // Present only on invoice lines (persisted in JSONB); nil on profile/rate-card configs. - TaxCode *taxcode.TaxCode `json:"tax_code,omitempty"` } func (c *TaxConfig) Equal(v *TaxConfig) bool { @@ -73,6 +70,10 @@ func (c *TaxConfig) Equal(v *TaxConfig) bool { } func (c *TaxConfig) Validate() error { + if c == nil { + return nil + } + var errs []error if c.Behavior != nil { @@ -105,26 +106,25 @@ func (c TaxConfig) Clone() TaxConfig { out.TaxCodeID = lo.ToPtr(*c.TaxCodeID) } - if c.TaxCode != nil { - tc := *c.TaxCode - tc.AppMappings = append(taxcode.TaxCodeAppMappings(nil), c.TaxCode.AppMappings...) - if c.TaxCode.Description != nil { - tc.Description = lo.ToPtr(*c.TaxCode.Description) - } - out.TaxCode = &tc - } - return out } +// MergeTaxConfigs merges two TaxConfigs with overrides taking precedence. +// +// Stripe and TaxCodeID are two encodings of the same intent-level tax-code identity, so they +// merge as a unit: a config that overrides only the Stripe code must not inherit the base's +// (different) TaxCodeID, which would leave the result pointing at two different tax entities. func MergeTaxConfigs(base, overrides *TaxConfig) *TaxConfig { if base != nil && overrides != nil { - // TaxCode (resolved entity) is intentionally excluded: merge operates on - // intent-level configs, not snapshotted invoice lines. + stripe, taxCodeID := base.Stripe, base.TaxCodeID + if overrides.Stripe != nil || overrides.TaxCodeID != nil { + stripe, taxCodeID = overrides.Stripe, overrides.TaxCodeID + } + return &TaxConfig{ Behavior: lo.CoalesceOrEmpty(overrides.Behavior, base.Behavior), - Stripe: lo.CoalesceOrEmpty(overrides.Stripe, base.Stripe), - TaxCodeID: lo.CoalesceOrEmpty(overrides.TaxCodeID, base.TaxCodeID), + Stripe: stripe, + TaxCodeID: taxCodeID, } } diff --git a/openmeter/productcatalog/tax_test.go b/openmeter/productcatalog/tax_test.go index 9ce6cc4bfc..9c1a75a25a 100644 --- a/openmeter/productcatalog/tax_test.go +++ b/openmeter/productcatalog/tax_test.go @@ -406,7 +406,11 @@ func TestMergeTaxConfigs(t *testing.T) { }, }, { - Name: "Right overrides left partially - TaxCodeID", + // The override supplies a new TaxCodeID but no Stripe code. Because Stripe and + // TaxCodeID are an atomic pair, the merge must take the override's pair (new + // TaxCodeID, nil Stripe) rather than leaking the base's Stripe through. The + // base's Stripe references a different tax entity and must not appear in the result. + Name: "Right overrides left partially - TaxCodeID only clears base Stripe (atomic pair)", Left: &TaxConfig{ Behavior: lo.ToPtr(InclusiveTaxBehavior), TaxCodeID: lo.ToPtr("01AN4Z07BY79KA1307SR9X4MV3"), @@ -421,9 +425,27 @@ func TestMergeTaxConfigs(t *testing.T) { Expected: &TaxConfig{ Behavior: lo.ToPtr(InclusiveTaxBehavior), TaxCodeID: lo.ToPtr("01AN4Z07BY79KA1307SR9X4MV4"), - Stripe: &StripeTaxConfig{ - Code: "txcd_99999999", - }, + Stripe: nil, // base Stripe must NOT leak through + }, + }, + { + // Regression guard: base has both Stripe and TaxCodeID (pointing at the same entity); + // override has ONLY a new Stripe code (no TaxCodeID). The merged result must carry the + // override's Stripe and must NOT inherit the base's TaxCodeID (which references a + // different entity). Leaking the base TaxCodeID alongside the override Stripe would + // produce an inconsistent pair that breaks snapshot readback. + Name: "Right overrides left partially - Stripe only does not leak base TaxCodeID", + Left: &TaxConfig{ + Stripe: &StripeTaxConfig{Code: "txcd_10000000"}, + TaxCodeID: lo.ToPtr("01AN4Z07BY79KA1307SR9X4MV3"), + }, + Right: &TaxConfig{ + Stripe: &StripeTaxConfig{Code: "txcd_20000000"}, + // TaxCodeID intentionally nil — override owns a different code entity. + }, + Expected: &TaxConfig{ + Stripe: &StripeTaxConfig{Code: "txcd_20000000"}, + TaxCodeID: nil, // base TaxCodeID must NOT leak through }, }, { @@ -449,21 +471,6 @@ func TestMergeTaxConfigs(t *testing.T) { }, }, }, - { - Name: "TaxCode resolved entity is dropped — merge is intent-level only", - Left: &TaxConfig{ - TaxCodeID: lo.ToPtr("01AN4Z07BY79KA1307SR9X4MV3"), - TaxCode: &taxcode.TaxCode{NamespacedID: models.NamespacedID{ID: "01AN4Z07BY79KA1307SR9X4MV3"}}, - }, - Right: &TaxConfig{ - Behavior: lo.ToPtr(ExclusiveTaxBehavior), - }, - Expected: &TaxConfig{ - TaxCodeID: lo.ToPtr("01AN4Z07BY79KA1307SR9X4MV3"), - Behavior: lo.ToPtr(ExclusiveTaxBehavior), - // TaxCode intentionally omitted from merge result - }, - }, } for _, test := range tests { @@ -515,40 +522,6 @@ func TestTaxConfigClone(t *testing.T) { assert.False(t, original.Equal(&cloned)) } -func TestTaxConfigCloneWithTaxCode(t *testing.T) { - desc := "Software - SaaS" - original := TaxConfig{ - Stripe: &StripeTaxConfig{Code: "txcd_10000000"}, - TaxCode: &taxcode.TaxCode{ - NamespacedID: models.NamespacedID{Namespace: "ns", ID: "tc-1"}, - Description: &desc, - AppMappings: taxcode.TaxCodeAppMappings{ - {AppType: app.AppTypeStripe, TaxCode: "txcd_10000000"}, - }, - }, - } - - cloned := original.Clone() - - // TaxCode pointer must not be shared - assert.NotSame(t, original.TaxCode, cloned.TaxCode) - - // Mutating clone's AppMappings must not affect original - cloned.TaxCode.AppMappings = append(cloned.TaxCode.AppMappings, taxcode.TaxCodeAppMapping{ - AppType: app.AppTypeStripe, TaxCode: "txcd_99999999", - }) - assert.Len(t, original.TaxCode.AppMappings, 1, "original AppMappings slice must not grow") - - // Mutating clone's Description must not affect original - *cloned.TaxCode.Description = "mutated" - assert.Equal(t, "Software - SaaS", *original.TaxCode.Description) - - // Clone of config with nil TaxCode must have nil TaxCode - nilTCConfig := TaxConfig{Stripe: &StripeTaxConfig{Code: "txcd_10000000"}} - clonedNil := nilTCConfig.Clone() - assert.Nil(t, clonedNil.TaxCode) -} - func TestBackfillTaxConfig(t *testing.T) { newTaxCode := func(id, stripeCode string) *taxcode.TaxCode { tc := &taxcode.TaxCode{ diff --git a/openmeter/taxcode/errors.go b/openmeter/taxcode/errors.go index 793ddcc222..b79203ba7f 100644 --- a/openmeter/taxcode/errors.go +++ b/openmeter/taxcode/errors.go @@ -146,3 +146,13 @@ func IsTaxCodeIsOrganizationDefaultError(err error) bool { var vi models.ValidationIssue return errors.As(err, &vi) && vi.Code() == ErrCodeTaxCodeIsOrganizationDefault } + +// ErrTaxCodeOrphanedKey is returned by GetOrCreateByAppMapping when a key derived from +// the Stripe code already exists but no longer carries that app mapping (the mapping was +// changed after the key was auto-created). The typed error prevents a raw constraint error from +// poisoning the pg transaction (25P02). +var ErrTaxCodeOrphanedKey = errors.New("tax code key exists but app mapping is orphaned") + +func IsTaxCodeOrphanedKeyError(err error) bool { + return errors.Is(err, ErrTaxCodeOrphanedKey) +} diff --git a/openmeter/taxcode/service/taxcode.go b/openmeter/taxcode/service/taxcode.go index 6c19dcfbf0..56507ec071 100644 --- a/openmeter/taxcode/service/taxcode.go +++ b/openmeter/taxcode/service/taxcode.go @@ -101,7 +101,16 @@ func (s *Service) GetOrCreateByAppMapping(ctx context.Context, input taxcode.Get if err != nil { // Another request may have created it concurrently. if models.IsGenericConflictError(err) { - return s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input)) + tc, retryErr := s.adapter.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput(input)) + if retryErr != nil { + if taxcode.IsTaxCodeNotFoundError(retryErr) { + // The key derived from this Stripe code exists but its app mapping was changed + // after auto-creation (orphaned key). Avoid poisoning the pg tx. + return taxcode.TaxCode{}, fmt.Errorf("resolving orphaned tax code key for %q: %w", input.TaxCode, taxcode.ErrTaxCodeOrphanedKey) + } + return taxcode.TaxCode{}, retryErr + } + return tc, nil } return taxcode.TaxCode{}, err diff --git a/openmeter/taxcode/taxcode.go b/openmeter/taxcode/taxcode.go index 2448f8192b..77e98a23fc 100644 --- a/openmeter/taxcode/taxcode.go +++ b/openmeter/taxcode/taxcode.go @@ -86,13 +86,61 @@ type TaxCode struct { Annotations models.Annotations `json:"annotations,omitempty"` } +// Equal returns true when both TaxCode values carry identical semantic data. +// Compares ID, Namespace, Key, Name, Description, and AppMappings. +// ManagedModel timestamps, Metadata, and Annotations are excluded. +func (t *TaxCode) Equal(v *TaxCode) bool { + if t == nil && v == nil { + return true + } + + if t == nil || v == nil { + return false + } + + if t.ID != v.ID || t.Namespace != v.Namespace { + return false + } + + if t.Key != v.Key || t.Name != v.Name { + return false + } + + if (t.Description == nil) != (v.Description == nil) { + return false + } + + if t.Description != nil && *t.Description != *v.Description { + return false + } + + if len(t.AppMappings) != len(v.AppMappings) { + return false + } + + left := lo.SliceToMap(t.AppMappings, func(m TaxCodeAppMapping) (app.AppType, string) { + return m.AppType, m.TaxCode + }) + + for _, m := range v.AppMappings { + code, ok := left[m.AppType] + if !ok || code != m.TaxCode { + return false + } + } + + return true +} + // IsManagedBySystem returns true when this tax code was auto-created by the system. func (t TaxCode) IsManagedBySystem() bool { v, ok := t.Annotations[AnnotationKeyManagedBy] + if !ok { return false } s, ok := v.(string) + return ok && s == AnnotationValueManagedBySystem } diff --git a/test/billing/taxcode_dual_write_test.go b/test/billing/taxcode_dual_write_test.go index 740a05c2e0..0084fc062a 100644 --- a/test/billing/taxcode_dual_write_test.go +++ b/test/billing/taxcode_dual_write_test.go @@ -744,9 +744,9 @@ func (s *TaxCodeDualWriteTestSuite) TestSimulateInvoiceReadOnly() { PaymentTerm: productcatalog.InAdvancePaymentTerm, ManagedBy: billing.ManuallyManagedLine, }) - line.TaxConfig = &productcatalog.TaxConfig{ + line.TaxConfig = billing.FromProductCatalog(&productcatalog.TaxConfig{ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_99999999"}, - } + }) result, err := s.BillingService.SimulateInvoice(ctx, billing.SimulateInvoiceInput{ Namespace: ns,