Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c97f380
feat(reconcile): add BillingProduct kind spec, validation, and diff
rohilsurana Jul 29, 2026
48f4fca
feat(reconcile): add BillingProduct reconciler and register the kind
rohilsurana Jul 29, 2026
16a6e40
docs(reconcile): document the BillingProduct kind
rohilsurana Jul 29, 2026
f3f1b53
fix(reconcile): make the BillingProduct diff merge-aware and drop dup…
rohilsurana Jul 29, 2026
4482e4f
fix(reconcile): send BillingProduct metadata only on create
rohilsurana Jul 29, 2026
fb4ab00
fix(billing): omit an empty plan id when creating a product
rohilsurana Jul 29, 2026
be9a411
test(reconcile): model the server merge and assert BillingProduct con…
rohilsurana Jul 29, 2026
f984b75
docs(reconcile): note BillingProduct merge limits and unsupported cases
rohilsurana Jul 29, 2026
bbdca63
fix(billing): default metered aggregate and lowercase currency so a p…
rohilsurana Jul 29, 2026
6a227bb
fix(reconcile): check all immutable price violations before adds so t…
rohilsurana Jul 29, 2026
c536398
chore: bump proton pin to allow one-time prices (empty interval)
rohilsurana Jul 29, 2026
543d921
fix(reconcile): reject billing product price changes the server would…
rohilsurana Jul 29, 2026
4411fe9
docs(reconcile): note billing price reuse and export ordering
rohilsurana Jul 29, 2026
5c75059
feat(reconcile): treat billing product title, description, and config…
rohilsurana Jul 30, 2026
1542d9a
feat(reconcile): validate the billing product request against the pro…
rohilsurana Jul 30, 2026
0da4fa8
docs(reconcile): describe full-write scalars and plan-time enum check…
rohilsurana Jul 30, 2026
a761fc8
refactor(billing): remove plan_id from the product create/update RPCs…
rohilsurana Jul 30, 2026
50f2ae8
fix(reconcile): require a product title, validate enums in Validate, …
rohilsurana Jul 30, 2026
e8837ed
docs(reconcile): note the title requirement, up-front enum checks, an…
rohilsurana Jul 30, 2026
774450d
fix(reconcile): drop product metadata from scope, fail a behavior cha…
rohilsurana Jul 30, 2026
230edfc
docs(reconcile): metadata out of scope, behavior change fails the pla…
rohilsurana Jul 30, 2026
8fe9ef1
fix(reconcile): fail the plan when the file names an out-of-scope bil…
rohilsurana Jul 30, 2026
644a8c2
docs(reconcile): note that naming an out-of-scope product fails the plan
rohilsurana Jul 30, 2026
254aec7
fix(reconcile): ignore metered_aggregate for non-metered prices, sour…
rohilsurana Jul 31, 2026
9fb7c96
fix(reconcile): drop metered_aggregate from non-metered prices on exp…
rohilsurana Jul 31, 2026
e561b10
test(reconcile): cover metered-aggregate defaulting and an empty feat…
rohilsurana Jul 31, 2026
8a37e7d
docs(reconcile): note what an omitted behavior means
rohilsurana Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "f90d337079080c48c6d071982baf175e9f76f033"
PROTON_COMMIT := "91eaffcdc8435ee129f9f93b43ad957c32efee62"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

admin-app:
@echo " > generating admin build"
Expand Down
15 changes: 15 additions & 0 deletions billing/product/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,17 @@ func normalizePrice(p Price) Price {
if p.Currency == "" {
p.Currency = "usd"
}
p.Currency = strings.ToLower(p.Currency)
if p.UsageType == "" {
p.UsageType = PriceUsageTypeLicensed
}
// metered_aggregate defaults to "sum": the create path fills it via
// SetDefaults while the add-a-price path leaves it empty, so both sides must
// normalize to the same value or the immutability check would falsely reject
// one against the other.
if p.MeteredAggregate == "" {
p.MeteredAggregate = "sum"
}
p.Interval = strings.ToLower(p.Interval)
p.Name = priceKey(p.Name)
return p
Expand Down Expand Up @@ -433,9 +441,16 @@ func (s *Service) CreatePrice(ctx context.Context, price Price) (Price, error) {
if price.Currency == "" {
price.Currency = "usd"
}
price.Currency = strings.ToLower(price.Currency)
if price.UsageType == "" {
price.UsageType = PriceUsageTypeLicensed
}
// store a consistent metered_aggregate so a price added through this path
// matches one the create path stored via SetDefaults, and a metered price
// sends a valid aggregate to the provider.
if price.MeteredAggregate == "" {
price.MeteredAggregate = "sum"
}
price.Interval = strings.ToLower(price.Interval)
price.Name = strings.ToLower(price.Name)

Expand Down
48 changes: 39 additions & 9 deletions billing/product/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,35 @@ func TestService_Update_ConvergesPrices(t *testing.T) {
}
})

t.Run("does not reject a metered price whose desired aggregate is omitted", func(t *testing.T) {
stripeClient, be, pr, priceRepo, fr := mockService(t)
expectProductNoise(be, pr, fr)
priceRepo.EXPECT().List(mock.Anything, product.Filter{ProductID: "prod-1"}).
Return([]product.Price{{ID: "price-metered", Name: "metered", Amount: 1, Currency: "usd", Interval: "month", UsageType: product.PriceUsageTypeMetered, MeteredAggregate: "sum", State: "active"}}, nil)

// the desired price omits metered_aggregate; the "sum" default must smooth
// it so the immutable check does not falsely reject on a later update.
metered := product.Price{Name: "metered", Amount: 1, Currency: "usd", Interval: "month", UsageType: product.PriceUsageTypeMetered}
svc := product.NewService(stripeClient, pr, priceRepo, fr)
if _, err := svc.Update(ctx, desired(metered)); err != nil {
t.Fatalf("Update() unexpected error = %v", err)
}
})

t.Run("does not reject a currency-case difference", func(t *testing.T) {
stripeClient, be, pr, priceRepo, fr := mockService(t)
expectProductNoise(be, pr, fr)
priceRepo.EXPECT().List(mock.Anything, product.Filter{ProductID: "prod-1"}).
Return([]product.Price{{ID: "price-monthly", Name: "monthly", Amount: 100, Currency: "usd", Interval: "month", State: "active"}}, nil)

upper := monthly
upper.Currency = "USD" // server stored "usd"; must not read as a change
svc := product.NewService(stripeClient, pr, priceRepo, fr)
if _, err := svc.Update(ctx, desired(upper)); err != nil {
t.Fatalf("Update() unexpected error = %v", err)
}
})

t.Run("empty price list leaves existing prices untouched", func(t *testing.T) {
stripeClient, be, pr, priceRepo, fr := mockService(t)
expectProductNoise(be, pr, fr)
Expand Down Expand Up @@ -749,7 +778,7 @@ func TestService_CreatePrice(t *testing.T) {
ID: "1",
Name: "price1",
Amount: 100,
Currency: "usd",
Currency: "USD", // stored lowercased
ProductID: "1",
BillingScheme: product.BillingSchemeFlat,
UsageType: product.PriceUsageTypeLicensed,
Expand All @@ -770,14 +799,15 @@ func TestService_CreatePrice(t *testing.T) {
setup: func() *product.Service {
stripeClient, mockStripeBackend, mockProductRepo, mockPriceRepo, mockFeatureRepo := mockService(t)
mockPriceRepo.EXPECT().Create(ctx, product.Price{
ID: "1",
Name: "price1",
Amount: 100,
Currency: "usd",
ProductID: "1",
BillingScheme: product.BillingSchemeFlat,
UsageType: product.PriceUsageTypeLicensed,
Interval: "month",
ID: "1",
Name: "price1",
Amount: 100,
Currency: "usd", // lowercased from "USD"
ProductID: "1",
BillingScheme: product.BillingSchemeFlat,
UsageType: product.PriceUsageTypeLicensed,
MeteredAggregate: "sum", // defaulted
Interval: "month",
}).Return(product.Price{
ID: "1",
Name: "price1",
Expand Down
23 changes: 13 additions & 10 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {

Kinds: PlatformUser (platform admins and members), Permission (custom
permissions), Role (platform-level roles), Preference (platform
settings), and Webhook (webhook endpoints). Deleting a permission, a
custom role, or a webhook needs an explicit 'delete: true' on its entry;
nothing is deleted by omission, and a predefined role cannot be deleted. A
preference left out of the file resets to its default. Log in as a superuser
(for example the bootstrap service account) with --header.
settings), Webhook (webhook endpoints), and BillingProduct (billing
products and their prices). Deleting a permission, a custom role, or a
webhook needs an explicit 'delete: true' on its entry; nothing is deleted
by omission, a predefined role cannot be deleted, and a product cannot be
deleted through the API. A preference left out of the file resets to its
default. Log in as a superuser (for example the bootstrap service account)
with --header.

Use "frontier export <kind>" to print the current state in this file format.
`),
Expand Down Expand Up @@ -84,11 +86,12 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile
}
api := reconcileAPI{AdminServiceClient: adminClient, FrontierServiceClient: frontierClient}
return map[string]reconcile.Reconciler{
reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header),
reconcile.KindPermission: reconcile.NewPermissionReconciler(api, header),
reconcile.KindRole: reconcile.NewRoleReconciler(api, header),
reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header),
reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header),
reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header),
reconcile.KindPermission: reconcile.NewPermissionReconciler(api, header),
reconcile.KindRole: reconcile.NewRoleReconciler(api, header),
reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header),
reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header),
reconcile.KindBillingProduct: reconcile.NewBillingProductReconciler(api, header),
}, nil
}

Expand Down
69 changes: 68 additions & 1 deletion docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,72 @@ spec:
empty, so reconciling an export plans nothing, and headers and metadata set through other
tools are carried through an update untouched.

## The BillingProduct kind

`BillingProduct` manages billing products: the thing a customer buys, its prices, and the
features attached to it. The product name is the identity and never changes.

```yaml
apiVersion: v1
kind: BillingProduct
spec:
- name: standard_plan_product
title: Standard Plan
behavior: basic
features:
- name: order_archive_images
- name: open_source_data
prices:
- name: monthly
amount: 15000
currency: usd
interval: month
- name: tokens
title: Tokens
behavior: credits
config:
credit_amount: 1
min_quantity: 1
max_quantity: 100000
prices:
- name: default
amount: 100
currency: usd
```

- The product name is the identity and must be at least three characters. A `title` is
required, because the billing provider uses it as the product name and does not allow an
empty one. `title`, `description`, `config`, `prices`, and `features` are the managed fields.
- `title`, `description`, and `config` state the whole desired value. The file writes them as
given, so leaving `description` out or zeroing a `config` field resets it. `behavior` is set
only when the product is created and cannot change afterward; a file that asks to change it
fails the plan. An omitted `behavior` becomes `basic` on create (or `credits` when
`credit_amount` is set) and is left as is on update. Metadata is out of scope for this kind:
it is never set, changed, or exported here.
- The valid values for `behavior`, `interval`, `usage_type`, and `billing_scheme` are checked
against the API's own rules when the file is validated, up front, so a wrong value fails
before anything applies. This kind does not keep its own copy of those lists, so it stays in
step with the server as the lists change.
Comment thread
rohilsurana marked this conversation as resolved.
- Prices are keyed by their name within the product. A new price name is added, and an active
price the file no longer lists is retired (marked inactive, not deleted, because a provider
price cannot be removed). Listing a retired name again brings it back, as long as its fields
match. Reusing a retired name with different fields fails the plan, because the old price
still exists on the server. Amount, currency, interval, and the other pricing fields cannot
change once a price exists: to change an amount, add a new price under a new name and drop
the old one. Changing a field on an existing price name fails the plan with that advice. A
tiered billing scheme is not supported. An empty price list is left alone, so a product's
last price cannot be removed through the file; retire it by hand.
- Features are attached by name. A feature that does not exist yet is created.
- Every product on the server must appear in the file. A product that is missing fails the
plan. There is no API to remove a product, so `delete: true` is rejected; archive a product
by hand. The one exception is a product this kind cannot represent — one that uses a tiered
price, has an empty title, or has a name shorter than three characters. That product is out
of scope: it is left untouched, not required in the file, and not exported, and a file that
names it fails the plan instead of trying to recreate it.
- Export writes each product sorted by name, and each product's active prices sorted by name,
so reconciling an export plans nothing. Provider ids, timestamps, and price state are
server-owned and not written; metadata and out-of-scope products are left out too.

## Running it

Log in as a superuser. The bootstrap service user exists for exactly this; its client id
Expand Down Expand Up @@ -278,7 +344,8 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an

## More kinds

This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, and `Webhook`. The design and
This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, `Webhook`, and
`BillingProduct`. The design and
the rules every kind follows live in
[RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md),
which also lists the kinds proposed next. The flag reference for both commands is in the
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26.5

require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
buf.build/go/protovalidate v1.0.0
connectrpc.com/connect v1.19.0
connectrpc.com/cors v0.1.0
connectrpc.com/grpchealth v1.4.0
Expand Down Expand Up @@ -48,7 +49,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.uber.org/zap v1.26.0
gocloud.dev v0.28.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478
Expand All @@ -61,7 +61,6 @@ require (
)

require (
buf.build/go/protovalidate v1.0.0 // indirect
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go/auth v0.3.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
Expand Down Expand Up @@ -131,6 +130,7 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
Expand Down
5 changes: 2 additions & 3 deletions internal/api/v1beta1connect/billing_product.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ func (h *ConnectHandler) CreateProduct(ctx context.Context, request *connect.Req
}
}
newProduct, err := h.productService.Create(ctx, product.Product{
PlanIDs: []string{request.Msg.GetBody().GetPlanId()},
Name: request.Msg.GetBody().GetName(),
Title: request.Msg.GetBody().GetTitle(),
Description: request.Msg.GetBody().GetDescription(),
Expand All @@ -94,8 +93,8 @@ func (h *ConnectHandler) CreateProduct(ctx context.Context, request *connect.Req
Metadata: metaDataMap,
})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateProduct.Create: product_name=%s product_title=%s plan_id=%s behavior=%s price_count=%d feature_count=%d: %w",
request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(), request.Msg.GetBody().GetPlanId(),
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateProduct.Create: product_name=%s product_title=%s behavior=%s price_count=%d feature_count=%d: %w",
request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(),
request.Msg.GetBody().GetBehavior(), len(productPrices), len(productFeatures), err))
}

Expand Down
6 changes: 2 additions & 4 deletions internal/api/v1beta1connect/billing_product_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,14 +517,13 @@ func TestConnectHandler_CreateProduct(t *testing.T) {
Name: "Test Product",
Title: "Test Product Title",
Description: "Test product description",
PlanId: "plan-1",
Behavior: product.BasicBehavior.String(),
},
}),
want: nil,
wantErr: true,
wantErrCode: connect.CodeInternal,
wantErrMsg: errors.New("CreateProduct.Create: product_name=Test Product product_title=Test Product Title plan_id=plan-1 behavior=basic price_count=0 feature_count=0: service error"),
wantErrMsg: errors.New("CreateProduct.Create: product_name=Test Product product_title=Test Product Title behavior=basic price_count=0 feature_count=0: service error"),
},
{
name: "should create product successfully with minimal data",
Expand Down Expand Up @@ -557,7 +556,6 @@ func TestConnectHandler_CreateProduct(t *testing.T) {
Name: "Basic Product",
Title: "Basic Product Title",
Description: "Basic product description",
PlanId: "plan-1",
Behavior: product.BasicBehavior.String(),
BehaviorConfig: &frontierv1beta1.Product_BehaviorConfig{
SeatLimit: 10,
Expand Down Expand Up @@ -605,7 +603,7 @@ func TestConnectHandler_CreateProduct(t *testing.T) {
want: nil,
wantErr: true,
wantErrCode: connect.CodeInternal,
wantErrMsg: errors.New("CreateProduct.Create: product_name= product_title= plan_id= behavior= price_count=0 feature_count=0: validation error"),
wantErrMsg: errors.New("CreateProduct.Create: product_name= product_title= behavior= price_count=0 feature_count=0: validation error"),
},
}

Expand Down
Loading
Loading