Skip to content

feat(reconcile): add BillingProduct kind - #1816

Merged
rohilsurana merged 27 commits into
mainfrom
feat/reconcile-billing-product
Jul 31, 2026
Merged

feat(reconcile): add BillingProduct kind#1816
rohilsurana merged 27 commits into
mainfrom
feat/reconcile-billing-product

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Jul 29, 2026

Copy link
Copy Markdown
Member

What

Adds a BillingProduct reconcile kind. It manages billing products — their title, description, behavior, config, prices, and features — from a desired-state YAML file, using the product APIs (ListProducts, CreateProduct, UpdateProduct). Part of the declarative reconcile flow in RFC 0001, one kind per resource.

apiVersion: v1
kind: BillingProduct
spec:
  - name: tokens
    title: Tokens
    behavior: credits
    config: {credit_amount: 1, min_quantity: 1, max_quantity: 100000}
    prices:
      - {name: default, amount: 100, currency: usd}

How it follows the RFC's five rules

  • R1 scope & identity — a product is keyed by name, a price by name within its product, a feature by name; identity is never rewritten. Provider ids, timestamps, price state, and metadata are out of scope. A product this kind cannot represent (a tiered price, an empty title, or a name under three characters) is left untouched, and a file that names it fails the plan.
  • R2 the file is the desired statetitle, description, and config are full-write; a missing title fails the plan (the provider uses it as the product name and requires it). Behavior is create-only, so a change to it fails the plan. Prices converge: a new name is added, a retired name is reactivated when its fields match, an omitted active price is retired; an immutable-field change fails the plan. A product missing from the file fails the plan; delete: true is rejected (there is no product delete API).
  • R3 check before applyValidate() runs each entry server-free, including the proto's own enum and format rules via protovalidate, so a bad value fails the whole file up front rather than at apply.
  • R4 converge, not transact — one API call per product, adds before updates, no rollback, re-apply converges.
  • R5 export inverts reconcile — export is sorted and writes only active prices; reconciling an export plans zero changes, proven by round-trip tests.

Depends on (all merged)

Notes

  • Metadata is out of scope for this kind: it is never set, read, or exported here.
  • Prices are a converging set of immutable provider objects, so a product's last price cannot be removed through the file; retire it by hand.

Testing

Unit-tested at the validate, diff, and reconciler layers (convergence, retire/reactivate, immutable-change and out-of-scope failures, the export round-trip). Verified end to end against a local server plus a Stripe mock, and reviewed by an independent pass against all five rules. Build, tests, gofmt, vet, and lint are green.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview Jul 31, 2026 10:17am

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added billing product reconciliation, including creation, updates, feature management, and price lifecycle handling.
    • Added validation for product definitions, prices, duplicates, unsupported configurations, and immutable changes.
    • Added billing products to the reconcile command and registry.
    • Added stable export support for billing product desired state.
  • Bug Fixes

    • Standardized currencies to lowercase and defaulted metered price aggregation to sum.
    • Removed obsolete plan references from product creation requests.
  • Documentation

    • Documented billing product reconciliation format, validation rules, managed fields, and limitations.

Walkthrough

This PR adds billing product reconciliation support: price normalization, validation, diff planning, API integration, export, and CLI registration. The changes ensure consistent immutability comparisons and supply valid defaults for metered prices.

Changes

Billing product lifecycle

Layer / File(s) Summary
Price normalization and request contracts
billing/product/service.go, billing/product/service_test.go, internal/api/v1beta1connect/billing_product.go, internal/api/v1beta1connect/billing_product_test.go, test/e2e/regression/billing_test.go
Price currencies and metered aggregates are normalized during creation and comparison. Product creation no longer sends PlanId in request or sets it from PlanIds in the created entity. Tests verify lowercase currencies and default metered aggregates to "sum".
Billing product validation and diff planning
internal/reconcile/billingproduct.go, internal/reconcile/billingproduct_test.go
Added billing product types (BillingProductSpec, BillingProductConfig, BillingPriceSpec, BillingFeatureRef), validation rules, diff planning logic, managed-field comparison, feature matching, and price reconciliation with support for additions, reactivations, deactivations, and immutability checks. Comprehensive unit tests cover valid and invalid specs, no-op matches, product updates, deletion rejection, case-insensitive comparisons, and price lifecycle handling.
Reconciler implementation and convergence
internal/reconcile/billingproduct_reconciler.go, internal/reconcile/billingproduct_reconciler_test.go, go.mod, Makefile
Added BillingProductAPI interface, BillingProductReconciler type, and public methods (Kind, Validate, Reconcile, Export). Implements spec parsing, planning, protobuf validation, create/update application, deterministic export, current-state loading, unsupported-product filtering, and out-of-scope detection. Tests verify planning and application, proto validation errors, spec validation, and stateful convergence across product creation, updates, price retirement, reactivation, and feature removal. Added buf.build/go/protovalidate as direct dependency and updated PROTON_COMMIT.
CLI registration and documentation
cmd/reconcile.go, docs/content/docs/reconcile.mdx
Registered BillingProduct reconciler in the reconcile command using the shared API client and auth header. Updated documentation to describe product identity, creation-only behavior, API validation, price immutability, feature handling, product completeness, unsupported products, and deterministic export ordering.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • raystack/frontier#1804: Updates the same PROTON_COMMIT reference in Makefile.
  • raystack/frontier#1810: Extends related billing price normalization and convergence behavior with metered aggregate defaults and case-insensitive comparisons.
  • raystack/frontier#1811: Implements active and retired billing price lifecycle semantics that this PR's reconciliation logic directly uses.

Suggested reviewers: whoabhisheksah

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@coveralls

coveralls commented Jul 29, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 30618661356

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.4%) to 47.685%

Details

  • Coverage increased (+0.4%) from the base build.
  • Patch coverage: 27 uncovered changes across 3 files (435 of 462 lines covered, 94.16%).
  • 45 coverage regressions across 4 files.

Uncovered Changes

File Changed Covered %
internal/reconcile/billingproduct_reconciler.go 222 205 92.34%
cmd/reconcile.go 13 7 53.85%
internal/reconcile/billingproduct.go 217 213 98.16%
Total (5 files) 462 435 94.16%

Coverage Regressions

45 previously-covered lines in 4 files lost coverage.

File Lines Losing Coverage Coverage
core/membership/service.go 19 83.76%
core/project/service.go 19 78.35%
cmd/serve.go 6 0.0%
cmd/reconcile.go 1 44.32%

Coverage Stats

Coverage Status
Relevant Lines: 39432
Covered Lines: 18803
Line Coverage: 47.68%
Coverage Strength: 15.32 hits per line

💛 - Coveralls

Comment thread internal/reconcile/billingproduct.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
internal/reconcile/billingproduct_test.go (1)

186-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a subtest for the metered_aggregate default in the diff.

The case-difference tests cover currency, interval, and usage type. No test covers the "sum" default that normalizeBillingPrice applies. billing/product/service_test.go covers this at the service layer, but internal/reconcile holds its own copy of the defaults, so a drift there would not fail any test.

💚 Proposed subtest
t.Run("does not plan a change when a metered price omits its aggregate", func(t *testing.T) {
	cur := curToken()
	cur.Prices = []BillingPriceSpec{{
		Name: "default", Amount: 100, Currency: "usd", Interval: "month",
		UsageType: "metered", BillingScheme: "flat", MeteredAggregate: "sum",
	}}
	s := newBillingProduct()
	s.Prices[0].UsageType = "metered" // metered_aggregate omitted; "sum" is the default
	ops, err := diffBillingProducts([]BillingProductSpec{s}, []currentBillingProduct{cur})
	assert.NoError(t, err)
	assert.Empty(t, ops)
})

The validateBillingProductSpec table also has no case for a feature with an empty name. Consider adding {"empty feature name", func(s *BillingProductSpec) { s.Features[0].Name = "" }, "feature with no name"}.

internal/reconcile/billingproduct.go (1)

372-392: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reference the billing/product default constants instead of duplicating literals.

"licensed" and "flat" are already exported as product.PriceUsageTypeLicensed and product.BillingSchemeFlat; centralize all four defaults so changes in billing/product do not drift from internal/reconcile/billingproduct.go.

internal/reconcile/billingproduct_reconciler.go (2)

265-299: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the unused error return from billingProductBody.

billingProductBody never returns a non-nil error. The two call sites add an error branch that cannot execute. Return only *frontierv1beta1.ProductRequestBody and remove the branches.


303-305: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Compare the price state case-insensitively.

hasTieredPrice lowercases the billing scheme read from the server, but billingPriceStateActive compares the raw state. A state of "Active" would then be treated as retired, which would keep the price out of the export and change the diff. Use the same normalization for both server-read fields.

♻️ Proposed change
 func billingPriceStateActive(state string) bool {
-	return state == "" || state == "active"
+	s := strings.ToLower(strings.TrimSpace(state))
+	return s == "" || s == "active"
 }
internal/reconcile/billingproduct_reconciler_test.go (1)

319-325: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Return an error for an unknown product id in the fake.

UpdateProduct looks up f.byID[req.Msg.GetId()] and writes to p without a nil check. If the reconciler ever sends an id the fake does not hold, the test panics with a nil dereference instead of reporting the wrong id.

♻️ Proposed change
 	p := f.byID[req.Msg.GetId()]
+	if p == nil {
+		return nil, fmt.Errorf("unknown product id %q", req.Msg.GetId())
+	}
 	b := req.Msg.GetBody()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d396e028-e7e8-4c12-bf68-366a14a612c7

📥 Commits

Reviewing files that changed from the base of the PR and between 7b216ea and c478b06.

⛔ Files ignored due to path filters (2)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
  • proto/v1beta1/models.pb.go is excluded by !**/*.pb.go, !proto/**
📒 Files selected for processing (13)
  • Makefile
  • billing/product/service.go
  • billing/product/service_test.go
  • cmd/reconcile.go
  • docs/content/docs/reconcile.mdx
  • go.mod
  • internal/api/v1beta1connect/billing_product.go
  • internal/api/v1beta1connect/billing_product_test.go
  • internal/reconcile/billingproduct.go
  • internal/reconcile/billingproduct_reconciler.go
  • internal/reconcile/billingproduct_reconciler_test.go
  • internal/reconcile/billingproduct_test.go
  • test/e2e/regression/billing_test.go
💤 Files with no reviewable changes (1)
  • test/e2e/regression/billing_test.go

Comment thread Makefile

@whoAbhishekSah whoAbhishekSah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Went through the new kind. A few comments below, nothing blocking.

Comment thread internal/reconcile/billingproduct.go
Comment thread internal/reconcile/billingproduct.go
Comment thread internal/reconcile/billingproduct_reconciler.go Outdated
Comment thread internal/reconcile/billingproduct_reconciler.go
Comment thread internal/reconcile/billingproduct_reconciler.go Outdated
Comment thread internal/reconcile/billingproduct.go Outdated
Comment thread internal/reconcile/billingproduct.go Outdated
Comment thread internal/reconcile/billingproduct_test.go Outdated
…nge, skip products the kind cannot represent
…ling product instead of planning a doomed create
…ce the tiered scheme from product, rename shadowed vars
…ort, remove billingProductBody's unused error, note the ListProducts single-page assumption

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e852a44b-6105-401a-8a80-03f4989e51b8

📥 Commits

Reviewing files that changed from the base of the PR and between c478b06 and 8a37e7d.

⛔ Files ignored due to path filters (2)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
  • proto/v1beta1/models.pb.go is excluded by !**/*.pb.go, !proto/**
📒 Files selected for processing (13)
  • Makefile
  • billing/product/service.go
  • billing/product/service_test.go
  • cmd/reconcile.go
  • docs/content/docs/reconcile.mdx
  • go.mod
  • internal/api/v1beta1connect/billing_product.go
  • internal/api/v1beta1connect/billing_product_test.go
  • internal/reconcile/billingproduct.go
  • internal/reconcile/billingproduct_reconciler.go
  • internal/reconcile/billingproduct_reconciler_test.go
  • internal/reconcile/billingproduct_test.go
  • test/e2e/regression/billing_test.go
💤 Files with no reviewable changes (1)
  • test/e2e/regression/billing_test.go
🚧 Files skipped from review as they are similar to previous changes (11)
  • internal/api/v1beta1connect/billing_product.go
  • internal/api/v1beta1connect/billing_product_test.go
  • Makefile
  • go.mod
  • cmd/reconcile.go
  • internal/reconcile/billingproduct_test.go
  • billing/product/service.go
  • billing/product/service_test.go
  • internal/reconcile/billingproduct_reconciler.go
  • internal/reconcile/billingproduct_reconciler_test.go
  • internal/reconcile/billingproduct.go

Comment thread docs/content/docs/reconcile.mdx
@rohilsurana
rohilsurana enabled auto-merge (squash) July 31, 2026 10:19
@rohilsurana
rohilsurana merged commit 990e142 into main Jul 31, 2026
8 checks passed
@rohilsurana
rohilsurana deleted the feat/reconcile-billing-product branch July 31, 2026 10:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants