From 3dfba456aab811100203df56de951b691b61df6d Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 6 Nov 2026 14:01:30 +0530 Subject: [PATCH 1/6] feat(reconcile): add BillingFeature spec and diff --- internal/reconcile/billingfeature.go | 157 ++++++++++++++++++ internal/reconcile/billingfeature_test.go | 192 ++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 internal/reconcile/billingfeature.go create mode 100644 internal/reconcile/billingfeature_test.go diff --git a/internal/reconcile/billingfeature.go b/internal/reconcile/billingfeature.go new file mode 100644 index 000000000..c81728e0e --- /dev/null +++ b/internal/reconcile/billingfeature.go @@ -0,0 +1,157 @@ +package reconcile + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "google.golang.org/protobuf/types/known/structpb" +) + +const KindBillingFeature = "BillingFeature" + +type BillingFeatureSpec struct { + Name string `yaml:"name"` + Title string `yaml:"title,omitempty"` + Metadata map[string]any `yaml:"metadata,omitempty"` + Delete bool `yaml:"delete,omitempty"` +} + +type currentBillingFeature struct { + ID string + Name string + Title string + Metadata map[string]any + ProductIDs []string +} + +type billingFeatureOp struct { + action opAction + spec BillingFeatureSpec + current currentBillingFeature + detail string +} + +func (o billingFeatureOp) String() string { + if o.action == opUpdate { + return fmt.Sprintf("update feature %s (%s)", o.spec.Name, o.detail) + } + return fmt.Sprintf("add feature %s", o.spec.Name) +} + +func validateBillingFeatureSpec(s BillingFeatureSpec) error { + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("feature name is required") + } + if s.Delete { + return fmt.Errorf("feature %q cannot be deleted: there is no feature delete API; keep the entry in the file", s.Name) + } + if _, err := structpb.NewStruct(s.Metadata); err != nil { + return fmt.Errorf("feature %q metadata cannot be stored: %w", s.Name, err) + } + return nil +} + +func checkBillingFeatureSpecs(specs []BillingFeatureSpec) error { + seen := map[string]struct{}{} + for _, s := range specs { + if err := validateBillingFeatureSpec(s); err != nil { + return fmt.Errorf("invalid billing feature spec %q: %w", s.Name, err) + } + if _, dup := seen[s.Name]; dup { + return fmt.Errorf("feature %q is listed more than once", s.Name) + } + seen[s.Name] = struct{}{} + } + return nil +} + +func diffBillingFeatures(desired []BillingFeatureSpec, current []currentBillingFeature) ([]billingFeatureOp, error) { + if err := checkBillingFeatureSpecs(desired); err != nil { + return nil, err + } + + byName := make(map[string]currentBillingFeature, len(current)) + byLowerName := make(map[string]currentBillingFeature, len(current)) + for _, c := range current { + byName[c.Name] = c + byLowerName[strings.ToLower(c.Name)] = c + } + + listed := map[string]struct{}{} + var adds, updates []billingFeatureOp + for _, s := range desired { + cur, exists := byName[s.Name] + if !exists { + if other, caseOnly := byLowerName[strings.ToLower(s.Name)]; caseOnly { + return nil, fmt.Errorf("feature %q matches server feature %q only by case; feature names are case sensitive, so use the server name", s.Name, other.Name) + } + adds = append(adds, billingFeatureOp{action: opAdd, spec: s}) + continue + } + listed[cur.Name] = struct{}{} + changes, err := billingFeatureChanges(s, cur) + if err != nil { + return nil, err + } + if len(changes) > 0 { + updates = append(updates, billingFeatureOp{ + action: opUpdate, + spec: s, + current: cur, + detail: strings.Join(changes, ", "), + }) + } + } + + var unlisted []string + for _, c := range current { + if _, ok := listed[c.Name]; !ok { + unlisted = append(unlisted, c.Name) + } + } + if len(unlisted) > 0 { + sort.Strings(unlisted) + return nil, fmt.Errorf("features exist on the server but are not in the file: %s; a feature cannot be removed through the API, so add it to the file (frontier export billingfeature lists them all)", strings.Join(unlisted, ", ")) + } + + sortBillingFeatureOpsByName(adds) + sortBillingFeatureOpsByName(updates) + return append(adds, updates...), nil +} + +func billingFeatureChanges(s BillingFeatureSpec, cur currentBillingFeature) ([]string, error) { + var changes []string + if s.Title != cur.Title { + changes = append(changes, "title") + } + desiredMetadata, err := canonicalMetadata(s.Metadata) + if err != nil { + return nil, fmt.Errorf("feature %q desired metadata: %w", s.Name, err) + } + currentMetadata, err := canonicalMetadata(cur.Metadata) + if err != nil { + return nil, fmt.Errorf("feature %q server metadata: %w", s.Name, err) + } + if desiredMetadata != currentMetadata { + changes = append(changes, "metadata") + } + return changes, nil +} + +func canonicalMetadata(m map[string]any) (string, error) { + asStored, err := structpb.NewStruct(m) + if err != nil { + return "", err + } + b, err := json.Marshal(asStored.AsMap()) + if err != nil { + return "", err + } + return string(b), nil +} + +func sortBillingFeatureOpsByName(ops []billingFeatureOp) { + sort.Slice(ops, func(i, j int) bool { return ops[i].spec.Name < ops[j].spec.Name }) +} diff --git a/internal/reconcile/billingfeature_test.go b/internal/reconcile/billingfeature_test.go new file mode 100644 index 000000000..74d830e1b --- /dev/null +++ b/internal/reconcile/billingfeature_test.go @@ -0,0 +1,192 @@ +package reconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func curAuditLogs() currentBillingFeature { + return currentBillingFeature{ + ID: "f1", + Name: "audit_logs", + Title: "Audit Logs", + Metadata: map[string]any{"info": "Every action is recorded.", "rank": float64(5)}, + ProductIDs: []string{"p1", "p2"}, + } +} + +func auditLogsSpec() BillingFeatureSpec { + return BillingFeatureSpec{ + Name: "audit_logs", + Title: "Audit Logs", + Metadata: map[string]any{"info": "Every action is recorded.", "rank": 5}, + } +} + +func TestDiffBillingFeatures(t *testing.T) { + t.Run("adds a feature the server does not have", func(t *testing.T) { + desired := []BillingFeatureSpec{auditLogsSpec(), {Name: "co_pilot", Title: "Co-pilot"}} + ops, err := diffBillingFeatures(desired, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, opAdd, ops[0].action) + assert.Equal(t, "co_pilot", ops[0].spec.Name) + assert.Equal(t, "add feature co_pilot", ops[0].String()) + } + }) + + t.Run("updates a feature whose title differs", func(t *testing.T) { + s := auditLogsSpec() + s.Title = "Audit logs" + ops, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, opUpdate, ops[0].action) + assert.Equal(t, "update feature audit_logs (title)", ops[0].String()) + assert.Equal(t, "f1", ops[0].current.ID) + } + }) + + t.Run("updates a feature whose metadata differs", func(t *testing.T) { + s := auditLogsSpec() + s.Metadata["info"] = "See [the docs](https://docs.example.com)." + ops, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, "update feature audit_logs (metadata)", ops[0].String()) + } + }) + + t.Run("updates both title and metadata", func(t *testing.T) { + s := BillingFeatureSpec{Name: "audit_logs", Title: "Logs", Metadata: map[string]any{"info": "new"}} + ops, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, "update feature audit_logs (title, metadata)", ops[0].String()) + } + }) + + t.Run("is a no-op when only metadata key order and number form differ", func(t *testing.T) { + s := BillingFeatureSpec{ + Name: "audit_logs", + Title: "Audit Logs", + Metadata: map[string]any{"rank": 5.0, "info": "Every action is recorded."}, + } + ops, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + assert.Empty(t, ops) + + ops, err = diffBillingFeatures([]BillingFeatureSpec{auditLogsSpec()}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("an omitted title resets the title to empty", func(t *testing.T) { + s := auditLogsSpec() + s.Title = "" + ops, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, "update feature audit_logs (title)", ops[0].String()) + assert.Equal(t, "", ops[0].spec.Title) + } + }) + + t.Run("omitted or empty metadata resets the metadata to empty", func(t *testing.T) { + for _, md := range []map[string]any{nil, {}} { + s := auditLogsSpec() + s.Metadata = md + ops, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, "update feature audit_logs (metadata)", ops[0].String()) + } + } + }) + + t.Run("omitted metadata matches a feature with no metadata", func(t *testing.T) { + cur := curAuditLogs() + cur.Metadata = nil + cur.Title = "" + ops, err := diffBillingFeatures([]BillingFeatureSpec{{Name: "audit_logs"}}, []currentBillingFeature{cur}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("fails when a server feature is missing from the file", func(t *testing.T) { + other := currentBillingFeature{ID: "f2", Name: "workflows"} + _, err := diffBillingFeatures([]BillingFeatureSpec{auditLogsSpec()}, []currentBillingFeature{curAuditLogs(), other}) + assert.ErrorContains(t, err, "features exist on the server but are not in the file: workflows") + }) + + t.Run("rejects a delete flag", func(t *testing.T) { + s := auditLogsSpec() + s.Delete = true + _, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.ErrorContains(t, err, "cannot be deleted") + }) + + t.Run("fails when a name matches a server feature only by case", func(t *testing.T) { + s := auditLogsSpec() + s.Name = "Audit_Logs" + _, err := diffBillingFeatures([]BillingFeatureSpec{s}, []currentBillingFeature{curAuditLogs()}) + assert.ErrorContains(t, err, `feature "Audit_Logs" matches server feature "audit_logs" only by case`) + }) + + t.Run("fails on a duplicate name", func(t *testing.T) { + _, err := diffBillingFeatures([]BillingFeatureSpec{auditLogsSpec(), auditLogsSpec()}, []currentBillingFeature{curAuditLogs()}) + assert.ErrorContains(t, err, "listed more than once") + }) + + t.Run("allows server features whose names differ only by case", func(t *testing.T) { + upper := auditLogsSpec() + upper.Name = "Audit_Logs" + curUpper := curAuditLogs() + curUpper.ID = "f2" + curUpper.Name = "Audit_Logs" + ops, err := diffBillingFeatures([]BillingFeatureSpec{auditLogsSpec(), upper}, []currentBillingFeature{curAuditLogs(), curUpper}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("matches a server name with surrounding spaces exactly", func(t *testing.T) { + spaced := auditLogsSpec() + spaced.Name = "audit_logs " + curSpaced := curAuditLogs() + curSpaced.Name = "audit_logs " + ops, err := diffBillingFeatures([]BillingFeatureSpec{spaced}, []currentBillingFeature{curSpaced}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("fails on an empty name", func(t *testing.T) { + _, err := diffBillingFeatures([]BillingFeatureSpec{{Name: " "}}, nil) + assert.ErrorContains(t, err, "name is required") + }) + + t.Run("orders adds before updates, each by name", func(t *testing.T) { + current := []currentBillingFeature{ + {ID: "f1", Name: "zeta"}, + {ID: "f2", Name: "alpha"}, + } + desired := []BillingFeatureSpec{ + {Name: "zeta", Title: "Zeta"}, + {Name: "new_b"}, + {Name: "alpha", Title: "Alpha"}, + {Name: "new_a"}, + } + ops, err := diffBillingFeatures(desired, current) + assert.NoError(t, err) + var got []string + for _, op := range ops { + got = append(got, op.String()) + } + assert.Equal(t, []string{ + "add feature new_a", + "add feature new_b", + "update feature alpha (title)", + "update feature zeta (title)", + }, got) + }) +} From dabb36ca1d5d81aaa6931740dc22d885f873b0c0 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 6 Nov 2026 14:02:41 +0530 Subject: [PATCH 2/6] feat(reconcile): add BillingFeature reconciler --- .../reconcile/billingfeature_reconciler.go | 173 +++++++++++++++ .../billingfeature_reconciler_test.go | 207 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 internal/reconcile/billingfeature_reconciler.go create mode 100644 internal/reconcile/billingfeature_reconciler_test.go diff --git a/internal/reconcile/billingfeature_reconciler.go b/internal/reconcile/billingfeature_reconciler.go new file mode 100644 index 000000000..5a6b2c0f5 --- /dev/null +++ b/internal/reconcile/billingfeature_reconciler.go @@ -0,0 +1,173 @@ +package reconcile + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "buf.build/go/protovalidate" + "connectrpc.com/connect" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +type BillingFeatureAPI interface { + ListFeatures(context.Context, *connect.Request[frontierv1beta1.ListFeaturesRequest]) (*connect.Response[frontierv1beta1.ListFeaturesResponse], error) + CreateFeature(context.Context, *connect.Request[frontierv1beta1.CreateFeatureRequest]) (*connect.Response[frontierv1beta1.CreateFeatureResponse], error) + UpdateFeature(context.Context, *connect.Request[frontierv1beta1.UpdateFeatureRequest]) (*connect.Response[frontierv1beta1.UpdateFeatureResponse], error) +} + +type BillingFeatureReconciler struct { + client BillingFeatureAPI + header string +} + +func NewBillingFeatureReconciler(client BillingFeatureAPI, header string) *BillingFeatureReconciler { + return &BillingFeatureReconciler{client: client, header: header} +} + +func (r *BillingFeatureReconciler) Kind() string { return KindBillingFeature } + +func (r *BillingFeatureReconciler) Validate(spec []byte) error { + var specs []BillingFeatureSpec + if err := decodeSpec(spec, &specs); err != nil { + return fmt.Errorf("parse %s spec: %w", KindBillingFeature, err) + } + if err := checkBillingFeatureSpecs(specs); err != nil { + return err + } + for _, s := range specs { + if _, err := billingFeatureRequest(billingFeatureOp{action: opAdd, spec: s}); err != nil { + return err + } + } + return nil +} + +func (r *BillingFeatureReconciler) Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) { + var specs []BillingFeatureSpec + if err := decodeSpec(spec, &specs); err != nil { + return Report{}, fmt.Errorf("parse %s spec: %w", KindBillingFeature, err) + } + + all, err := r.fetchCurrent(ctx) + if err != nil { + return Report{}, err + } + current, outOfScope := splitOutOfScopeFeatures(all) + if err := checkOutOfScopeFeatures(specs, outOfScope); err != nil { + return Report{}, err + } + + ops, err := diffBillingFeatures(specs, current) + if err != nil { + return Report{}, err + } + + rep := Report{Kind: KindBillingFeature, DryRun: dryRun} + for _, op := range ops { + if _, err := billingFeatureRequest(op); err != nil { + return Report{}, fmt.Errorf("plan %s: %w", op, err) + } + rep.Planned = append(rep.Planned, op.String()) + } + if dryRun { + return rep, nil + } + for _, op := range ops { + if err := r.apply(ctx, op); err != nil { + return rep, fmt.Errorf("apply [%s]: %w", op, err) + } + rep.Applied++ + } + return rep, nil +} + +func (r *BillingFeatureReconciler) fetchCurrent(ctx context.Context) ([]currentBillingFeature, error) { + resp, err := r.client.ListFeatures(ctx, authReq(&frontierv1beta1.ListFeaturesRequest{}, r.header)) + if err != nil { + return nil, fmt.Errorf("list features: %w", err) + } + var current []currentBillingFeature + for _, f := range resp.Msg.GetFeatures() { + current = append(current, currentBillingFeature{ + ID: f.GetId(), + Name: f.GetName(), + Title: f.GetTitle(), + Metadata: f.GetMetadata().AsMap(), + ProductIDs: f.GetProductIds(), + }) + } + return current, nil +} + +func splitOutOfScopeFeatures(all []currentBillingFeature) ([]currentBillingFeature, []string) { + var inScope []currentBillingFeature + var outOfScope []string + for _, c := range all { + if utf8.RuneCountInString(c.Name) < 3 { + outOfScope = append(outOfScope, c.Name) + continue + } + inScope = append(inScope, c) + } + return inScope, outOfScope +} + +func checkOutOfScopeFeatures(specs []BillingFeatureSpec, outOfScope []string) error { + set := make(map[string]struct{}, len(outOfScope)) + for _, n := range outOfScope { + set[strings.ToLower(strings.TrimSpace(n))] = struct{}{} + } + for _, s := range specs { + if _, ok := set[strings.ToLower(strings.TrimSpace(s.Name))]; ok { + return fmt.Errorf("feature %q is out of scope for this kind: the server holds it with a name shorter than three characters, so it cannot be managed from the file; remove the entry and manage it by hand", s.Name) + } + } + return nil +} + +func (r *BillingFeatureReconciler) apply(ctx context.Context, op billingFeatureOp) error { + req, err := billingFeatureRequest(op) + if err != nil { + return err + } + switch req := req.(type) { + case *frontierv1beta1.CreateFeatureRequest: + _, err = r.client.CreateFeature(ctx, authReq(req, r.header)) + case *frontierv1beta1.UpdateFeatureRequest: + _, err = r.client.UpdateFeature(ctx, authReq(req, r.header)) + } + return err +} + +func billingFeatureRequest(op billingFeatureOp) (proto.Message, error) { + metadata, err := structpb.NewStruct(op.spec.Metadata) + if err != nil { + return nil, fmt.Errorf("feature %q metadata: %w", op.spec.Name, err) + } + var req proto.Message + switch op.action { + case opAdd: + req = &frontierv1beta1.CreateFeatureRequest{Body: &frontierv1beta1.FeatureRequestBody{ + Name: op.spec.Name, + Title: op.spec.Title, + Metadata: metadata, + }} + case opUpdate: + req = &frontierv1beta1.UpdateFeatureRequest{Id: op.current.ID, Body: &frontierv1beta1.FeatureRequestBody{ + Name: op.current.Name, + Title: op.spec.Title, + Metadata: metadata, + ProductIds: op.current.ProductIDs, + }} + default: + return nil, fmt.Errorf("unknown op action %q", op.action) + } + if err := protovalidate.Validate(req); err != nil { + return nil, fmt.Errorf("feature %q: %w", op.spec.Name, err) + } + return req, nil +} diff --git a/internal/reconcile/billingfeature_reconciler_test.go b/internal/reconcile/billingfeature_reconciler_test.go new file mode 100644 index 000000000..08eb04c9d --- /dev/null +++ b/internal/reconcile/billingfeature_reconciler_test.go @@ -0,0 +1,207 @@ +package reconcile + +import ( + "context" + "errors" + "testing" + + "connectrpc.com/connect" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" +) + +type fakeBillingFeatureAPI struct { + features []*frontierv1beta1.Feature + listErr error + updateErr error + created []*frontierv1beta1.CreateFeatureRequest + updated []*frontierv1beta1.UpdateFeatureRequest +} + +func (f *fakeBillingFeatureAPI) ListFeatures(_ context.Context, _ *connect.Request[frontierv1beta1.ListFeaturesRequest]) (*connect.Response[frontierv1beta1.ListFeaturesResponse], error) { + if f.listErr != nil { + return nil, f.listErr + } + return connect.NewResponse(&frontierv1beta1.ListFeaturesResponse{Features: f.features}), nil +} + +func (f *fakeBillingFeatureAPI) CreateFeature(_ context.Context, req *connect.Request[frontierv1beta1.CreateFeatureRequest]) (*connect.Response[frontierv1beta1.CreateFeatureResponse], error) { + f.created = append(f.created, req.Msg) + return connect.NewResponse(&frontierv1beta1.CreateFeatureResponse{}), nil +} + +func (f *fakeBillingFeatureAPI) UpdateFeature(_ context.Context, req *connect.Request[frontierv1beta1.UpdateFeatureRequest]) (*connect.Response[frontierv1beta1.UpdateFeatureResponse], error) { + if f.updateErr != nil { + return nil, f.updateErr + } + f.updated = append(f.updated, req.Msg) + return connect.NewResponse(&frontierv1beta1.UpdateFeatureResponse{}), nil +} + +func featurePB(t *testing.T, id, name, title string, metadata map[string]any, productIDs ...string) *frontierv1beta1.Feature { + t.Helper() + md, err := structpb.NewStruct(metadata) + assert.NoError(t, err) + return &frontierv1beta1.Feature{Id: id, Name: name, Title: title, Metadata: md, ProductIds: productIDs} +} + +func TestBillingFeatureReconciler(t *testing.T) { + t.Run("an update keeps the current name and product links and sends the desired title and metadata", func(t *testing.T) { + api := &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f1", "audit_logs", "Audit Logs", nil, "p1", "p2"), + }} + spec := []byte(` +- name: audit_logs + title: Audit logs + metadata: + info: "See [the docs](https://docs.example.com)." +`) + rep, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.NoError(t, err) + assert.Equal(t, []string{"update feature audit_logs (title, metadata)"}, rep.Planned) + assert.Equal(t, 1, rep.Applied) + if assert.Len(t, api.updated, 1) { + req := api.updated[0] + assert.Equal(t, "f1", req.GetId()) + assert.Equal(t, "audit_logs", req.GetBody().GetName()) + assert.Equal(t, []string{"p1", "p2"}, req.GetBody().GetProductIds()) + assert.Equal(t, "Audit logs", req.GetBody().GetTitle()) + assert.Equal(t, map[string]any{"info": "See [the docs](https://docs.example.com)."}, req.GetBody().GetMetadata().AsMap()) + } + assert.Empty(t, api.created) + }) + + t.Run("an update with omitted title and metadata sends them empty", func(t *testing.T) { + api := &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f1", "audit_logs", "Audit Logs", map[string]any{"info": "old"}, "p1"), + }} + _, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), []byte("- name: audit_logs\n"), false) + + assert.NoError(t, err) + if assert.Len(t, api.updated, 1) { + assert.Equal(t, "", api.updated[0].GetBody().GetTitle()) + assert.Empty(t, api.updated[0].GetBody().GetMetadata().AsMap()) + assert.Equal(t, []string{"p1"}, api.updated[0].GetBody().GetProductIds()) + } + }) + + t.Run("an update of a feature with no product links sends none", func(t *testing.T) { + api := &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f1", "audit_logs", "Audit Logs", nil), + }} + _, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), []byte("- {name: audit_logs, title: Logs}\n"), false) + + assert.NoError(t, err) + if assert.Len(t, api.updated, 1) { + assert.Empty(t, api.updated[0].GetBody().GetProductIds()) + } + }) + + t.Run("a server feature with a name shorter than three characters is out of scope", func(t *testing.T) { + api := &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f1", "audit_logs", "Audit Logs", nil), + featurePB(t, "f2", "ab", "", nil), + }} + rep, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), []byte("- {name: audit_logs, title: Audit Logs}\n"), true) + + assert.NoError(t, err) + assert.Empty(t, rep.Planned) + }) + + t.Run("a file that names an out of scope feature fails the plan", func(t *testing.T) { + api := &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f2", "ab", "", nil), + }} + _, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), []byte("- name: ab\n"), true) + + assert.ErrorContains(t, err, `feature "ab" is out of scope`) + }) + + t.Run("a create sends no product ids", func(t *testing.T) { + api := &fakeBillingFeatureAPI{} + spec := []byte("- {name: co_pilot, title: Co-pilot, metadata: {info: help}}\n") + + rep, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.NoError(t, err) + assert.Equal(t, []string{"add feature co_pilot"}, rep.Planned) + if assert.Len(t, api.created, 1) { + body := api.created[0].GetBody() + assert.Equal(t, "co_pilot", body.GetName()) + assert.Equal(t, "Co-pilot", body.GetTitle()) + assert.Equal(t, map[string]any{"info": "help"}, body.GetMetadata().AsMap()) + assert.Empty(t, body.GetProductIds()) + } + }) + + t.Run("a dry run makes no writes", func(t *testing.T) { + api := &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f1", "audit_logs", "Audit Logs", nil, "p1"), + }} + spec := []byte("- {name: audit_logs, title: Logs}\n- {name: co_pilot}\n") + + rep, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), spec, true) + + assert.NoError(t, err) + assert.True(t, rep.DryRun) + assert.Equal(t, []string{"add feature co_pilot", "update feature audit_logs (title)"}, rep.Planned) + assert.Zero(t, rep.Applied) + assert.Empty(t, api.created) + assert.Empty(t, api.updated) + }) + + t.Run("a name the server would reject fails the plan", func(t *testing.T) { + api := &fakeBillingFeatureAPI{} + _, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), []byte("- name: ab\n"), true) + assert.ErrorContains(t, err, "at least 3 characters") + }) + + t.Run("wraps a list error", func(t *testing.T) { + api := &fakeBillingFeatureAPI{listErr: errors.New("boom")} + _, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), []byte("- name: audit_logs\n"), true) + assert.ErrorContains(t, err, "list features: boom") + }) + + t.Run("an apply error returns the partial report", func(t *testing.T) { + api := &fakeBillingFeatureAPI{ + features: []*frontierv1beta1.Feature{ + featurePB(t, "f1", "audit_logs", "Audit Logs", nil, "p1"), + }, + updateErr: errors.New("boom"), + } + spec := []byte("- {name: audit_logs, title: Logs}\n- {name: co_pilot}\n") + + rep, err := NewBillingFeatureReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.ErrorContains(t, err, "apply [update feature audit_logs (title)]: boom") + assert.Equal(t, 1, rep.Applied) + assert.Len(t, api.created, 1) + }) +} + +func TestBillingFeatureReconcilerValidate(t *testing.T) { + rec := NewBillingFeatureReconciler(&fakeBillingFeatureAPI{}, "") + + assert.NoError(t, rec.Validate([]byte("- {name: audit_logs, title: Audit Logs, metadata: {info: help, rank: 5}}\n"))) + + cases := []struct { + name string + spec string + wantErr string + }{ + {"unknown field", "- {name: audit_logs, titel: Audit Logs}\n", "field titel not found"}, + {"metadata with a non-string nested key", "- {name: audit_logs, metadata: {limits: {1: one}}}\n", "metadata cannot be stored"}, + {"metadata with a timestamp", "- {name: audit_logs, metadata: {since: 2020-01-01}}\n", "metadata cannot be stored"}, + {"delete flag", "- {name: audit_logs, delete: true}\n", "cannot be deleted"}, + {"empty name", "- {title: Audit Logs}\n", "name is required"}, + {"duplicate name", "- {name: audit_logs}\n- {name: audit_logs}\n", "listed more than once"}, + {"name too short", "- {name: ab}\n", "at least 3 characters"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.ErrorContains(t, rec.Validate([]byte(c.spec)), c.wantErr) + }) + } +} From 1446ede7816b6c0c407a1e5a2666fe19b769d7b1 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 6 Nov 2026 14:03:11 +0530 Subject: [PATCH 3/6] feat(reconcile): add BillingFeature export --- .../reconcile/billingfeature_reconciler.go | 20 ++++++++ .../billingfeature_reconciler_test.go | 46 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/reconcile/billingfeature_reconciler.go b/internal/reconcile/billingfeature_reconciler.go index 5a6b2c0f5..c8e59fe4c 100644 --- a/internal/reconcile/billingfeature_reconciler.go +++ b/internal/reconcile/billingfeature_reconciler.go @@ -3,6 +3,7 @@ package reconcile import ( "context" "fmt" + "sort" "strings" "unicode/utf8" @@ -85,6 +86,25 @@ func (r *BillingFeatureReconciler) Reconcile(ctx context.Context, spec []byte, d return rep, nil } +func (r *BillingFeatureReconciler) Export(ctx context.Context) (any, error) { + all, err := r.fetchCurrent(ctx) + if err != nil { + return nil, err + } + current, _ := splitOutOfScopeFeatures(all) + sort.Slice(current, func(i, j int) bool { return current[i].Name < current[j].Name }) + + specs := make([]BillingFeatureSpec, 0, len(current)) + for _, c := range current { + entry := BillingFeatureSpec{Name: c.Name, Title: c.Title} + if len(c.Metadata) > 0 { + entry.Metadata = c.Metadata + } + specs = append(specs, entry) + } + return specs, nil +} + func (r *BillingFeatureReconciler) fetchCurrent(ctx context.Context) ([]currentBillingFeature, error) { resp, err := r.client.ListFeatures(ctx, authReq(&frontierv1beta1.ListFeaturesRequest{}, r.header)) if err != nil { diff --git a/internal/reconcile/billingfeature_reconciler_test.go b/internal/reconcile/billingfeature_reconciler_test.go index 08eb04c9d..0adcd91b4 100644 --- a/internal/reconcile/billingfeature_reconciler_test.go +++ b/internal/reconcile/billingfeature_reconciler_test.go @@ -205,3 +205,49 @@ func TestBillingFeatureReconcilerValidate(t *testing.T) { }) } } + +func TestBillingFeatureReconcilerExport(t *testing.T) { + newAPI := func(t *testing.T) *fakeBillingFeatureAPI { + t.Helper() + return &fakeBillingFeatureAPI{features: []*frontierv1beta1.Feature{ + featurePB(t, "f2", "workflows", "", nil, "p1"), + featurePB(t, "f1", "audit_logs", "Audit Logs", map[string]any{ + "info": "help", + "rank": 5, + "limits": map[string]any{"seats": 10, "plan": "pro"}, + "tags": []any{"beta", 2}, + }, "p1", "p2"), + featurePB(t, "f3", "ab", "Short", nil), + }} + } + + t.Run("writes in scope features sorted by name and leaves out empty title and metadata", func(t *testing.T) { + spec, err := NewBillingFeatureReconciler(newAPI(t), "").Export(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, []BillingFeatureSpec{ + {Name: "audit_logs", Title: "Audit Logs", Metadata: map[string]any{ + "info": "help", + "rank": float64(5), + "limits": map[string]any{"seats": float64(10), "plan": "pro"}, + "tags": []any{"beta", float64(2)}, + }}, + {Name: "workflows"}, + }, spec) + }) + + t.Run("reconciling the export plans no changes", func(t *testing.T) { + api := newAPI(t) + registry := map[string]Reconciler{KindBillingFeature: NewBillingFeatureReconciler(api, "")} + + out, err := Export(context.Background(), registry, KindBillingFeature) + assert.NoError(t, err) + assert.NotContains(t, string(out), "product") + + reports, err := Run(context.Background(), registry, out, true) + assert.NoError(t, err) + if assert.Len(t, reports, 1) { + assert.Empty(t, reports[0].Planned) + } + }) +} From 787e3d98736833a16343a291178f54edea58a167 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 6 Nov 2026 14:03:38 +0530 Subject: [PATCH 4/6] feat(reconcile): require BillingFeature before BillingProduct in a file --- internal/reconcile/reconcile.go | 3 ++- internal/reconcile/reconcile_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go index e136c4146..846ad27f4 100644 --- a/internal/reconcile/reconcile.go +++ b/internal/reconcile/reconcile.go @@ -54,7 +54,8 @@ type parsedDocument struct { // file, a dependency must not come after its dependent: that order would fail // mid-apply instead of up front. var kindDependencies = map[string][]string{ - KindRole: {KindPermission}, + KindRole: {KindPermission}, + KindBillingProduct: {KindBillingFeature}, } // parseDocuments reads and checks every document in the file before anything diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go index 7b3833efd..e3b89d6c9 100644 --- a/internal/reconcile/reconcile_test.go +++ b/internal/reconcile/reconcile_test.go @@ -157,3 +157,21 @@ func TestRun_ReturnsPartialReportOnError(t *testing.T) { assert.Len(t, reports[0].Planned, 3) } } + +func TestRun_BillingProductBeforeBillingFeatureFailsFast(t *testing.T) { + featureAPI := &fakeBillingFeatureAPI{} + productAPI := &fakeBillingProductAPI{} + registry := map[string]Reconciler{ + KindBillingFeature: NewBillingFeatureReconciler(featureAPI, ""), + KindBillingProduct: NewBillingProductReconciler(productAPI, ""), + } + + _, err := Run(context.Background(), registry, []byte("kind: BillingProduct\nspec:\n - {name: token, title: Tokens, features: [{name: audit_logs}]}\n---\nkind: BillingFeature\nspec:\n - {name: audit_logs}\n"), false) + + assert.ErrorContains(t, err, `kind "BillingFeature" must come before kind "BillingProduct"`) + assert.Empty(t, productAPI.created) + assert.Empty(t, featureAPI.created) + + _, err = Run(context.Background(), registry, []byte("kind: BillingFeature\nspec: []\n---\nkind: BillingProduct\nspec: []\n"), true) + assert.NoError(t, err) +} From f50640c97b3d55b8931a0814f7348709424efc01 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 6 Nov 2026 14:03:59 +0530 Subject: [PATCH 5/6] feat(cli): register BillingFeature reconcile kind --- cmd/reconcile.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 5037bfe10..7069e1ff2 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -24,12 +24,13 @@ func ReconcileCommand(cliConfig *Config) *cli.Command { Kinds: PlatformUser (platform admins and members), Permission (custom permissions), Role (platform-level roles), Preference (platform - settings), Webhook (webhook endpoints), BillingProduct (billing products - and their prices), BillingPlan (billing plans and the products they - bundle), and MetaSchema (metadata validation schemas). 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 or plan cannot be deleted through the API. A + settings), Webhook (webhook endpoints), BillingFeature (billing feature + titles and metadata), BillingProduct (billing products and their + prices), BillingPlan (billing plans and the products they bundle), and + MetaSchema (metadata validation schemas). 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 feature, product, or plan 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. @@ -93,6 +94,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile reconcile.KindRole: reconcile.NewRoleReconciler(api, header), reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header), reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header), + reconcile.KindBillingFeature: reconcile.NewBillingFeatureReconciler(api, header), reconcile.KindBillingProduct: reconcile.NewBillingProductReconciler(api, header), reconcile.KindBillingPlan: reconcile.NewBillingPlanReconciler(api, header), reconcile.KindMetaSchema: reconcile.NewMetaSchemaReconciler(api, header), From 662c0b35145d0e846a29bf1bdbccc32b67d87392 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 6 Nov 2026 14:04:40 +0530 Subject: [PATCH 6/6] docs(reconcile): document BillingFeature kind --- docs/content/docs/reconcile.mdx | 54 +++++++++++++++-- docs/content/docs/reference/cli.mdx | 10 ++-- ...ative-reconcile-addendum-billingfeature.md | 58 +++++++++++++++++++ 3 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 docs/rfcs/0001-declarative-reconcile-addendum-billingfeature.md diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx index a88d89d43..5acf00ab3 100644 --- a/docs/content/docs/reconcile.mdx +++ b/docs/content/docs/reconcile.mdx @@ -46,8 +46,9 @@ Rules that apply to every document: document stops the run before any change is made. - Documents apply in the order they appear in the file. - Some kinds depend on others. A role can grant a permission, so a `Permission` document - must come before a `Role` document in the same file. The wrong order is rejected before - anything applies. + must come before a `Role` document in the same file. A product lists features, so a + `BillingFeature` document must come before a `BillingProduct` document in the same file. + The wrong order is rejected before anything applies. ## The PlatformUser kind @@ -244,6 +245,49 @@ spec: empty, so reconciling an export plans nothing, and headers and metadata set through other tools are carried through an update untouched. +## The BillingFeature kind + +`BillingFeature` manages the title and metadata of billing features. A feature is a line on +a pricing page, like "Audit logs". The feature name is the identity and never changes. + +```yaml +apiVersion: v1 +kind: BillingFeature +spec: + - name: audit_logs + title: Audit Logs + metadata: + info: "Every action is recorded. See [the audit log docs](https://docs.example.com/audit-logs)." + - name: open_source_data + title: Open Source Data + - name: order_archive_images +``` + +- The feature name is the identity and must be at least three characters. Names are case + sensitive. A name that matches a server feature only by case fails the plan, so use the + name exactly as the server has it. +- `title` and `metadata` are the managed fields. Each one states the whole desired value. + A value you write is used as given. Leaving `title` out sets it to empty. Leaving + `metadata` out sets it to `{}`. Metadata is one whole map: a key you drop is removed. +- Metadata is compared by value. Key order does not matter, and `5` and `5.0` are the same. + The server stores every number as a decimal, so prefer strings for text like tooltips and + links. A value the API cannot hold, like a date or a nested map with a number as a key, fails + when the file is validated. +- Every feature on the server must appear in the file. A feature that is missing fails the + plan. There is no API to remove a feature, so `delete: true` is rejected. Run + `frontier export billingfeature` to get the full list. A feature with a name shorter than + three characters 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. +- A new feature is created with its title and metadata and no products. Products are linked + by the `BillingProduct` kind, not by this one. An update keeps the feature's current + product links. If a feature's products change during a run, by hand or by a + `BillingProduct` run from another process, that change can be overwritten. +- A `BillingProduct` can still list a feature that this kind has not set up. The product + creates it with an empty title and no metadata. Add it to the `BillingFeature` file too, + or the next run of that file fails because the feature is missing. +- Export writes each feature sorted by name. It leaves out an empty title and empty + metadata, and never writes product links. Reconciling an export plans nothing. + ## The BillingProduct kind `BillingProduct` manages billing products: the thing a customer buys, its prices, and the @@ -434,8 +478,10 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an ## More kinds This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, `Webhook`, -`BillingProduct`, `BillingPlan`, and `MetaSchema`. The design and +`BillingFeature`, `BillingProduct`, `BillingPlan`, and `MetaSchema`. 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 +which also lists the kinds proposed next. The BillingFeature kind is described in +[its addendum](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile-addendum-billingfeature.md). +The flag reference for both commands is in the [CLI reference](./reference/cli.md). diff --git a/docs/content/docs/reference/cli.mdx b/docs/content/docs/reference/cli.mdx index 471eb26ae..8f83b38db 100644 --- a/docs/content/docs/reference/cli.mdx +++ b/docs/content/docs/reference/cli.mdx @@ -32,7 +32,8 @@ List of supported environment variables Export the current state of a kind as a desired-state YAML file, printed to stdout. The output is the format `frontier reconcile` reads: reconciling it changes nothing. Supported kinds: `PlatformUser`, `Permission`, `Role`, -`Preference`, `Webhook`, `BillingProduct`, `BillingPlan`, and `MetaSchema`. See the +`Preference`, `Webhook`, `BillingFeature`, `BillingProduct`, `BillingPlan`, and +`MetaSchema`. See the [Reconcile guide](../reconcile.md) for the file format and the flow. ``` @@ -249,13 +250,14 @@ API. Supported kinds: `PlatformUser` (anyone listed is added, anyone not listed is removed), `Permission` (custom permissions), `Role` (platform-level roles), `Preference` (platform settings, where a setting left out of the file resets to its default), `Webhook` (webhook endpoints), -`BillingProduct` (billing products and their prices), `BillingPlan` (billing +`BillingFeature` (billing feature titles and metadata), `BillingProduct` +(billing products and their prices), `BillingPlan` (billing plans and the products they bundle), and `MetaSchema` (metadata validation schemas, where a schema left out resets to its shipped default). 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 billing product or plan cannot be deleted -through the API. Use +role cannot be deleted, and a billing feature, product, or plan cannot be +deleted through the API. Use `frontier export` to print the current state in this file format, and see the [Reconcile guide](../reconcile.md) for the full flow. diff --git a/docs/rfcs/0001-declarative-reconcile-addendum-billingfeature.md b/docs/rfcs/0001-declarative-reconcile-addendum-billingfeature.md new file mode 100644 index 000000000..1d7fa2d20 --- /dev/null +++ b/docs/rfcs/0001-declarative-reconcile-addendum-billingfeature.md @@ -0,0 +1,58 @@ +# RFC 0001 addendum: the BillingFeature kind + +| | | +|---|---| +| **Status** | Accepted. Implemented in the `reconcile` package and the `reconcile` and `export` commands. | +| **Author** | Rohil Surana | +| **Created** | 2026-11-07 | +| **Updated** | 2026-11-07 | +| **Extends** | [RFC 0001: Declarative management of platform resources](0001-declarative-reconcile.md) | + +## Summary + +This addendum adds one kind, BillingFeature, to the kinds in RFC 0001. It follows the five +rules of RFC 0001. RFC 0001 itself is not changed. + +Before this kind, a file could only name a feature inside a BillingProduct. A feature's title +and metadata could not be set from a file. They lived only in the database. BillingFeature lets +a file set them, so a UI can show a feature's title and a tooltip from its metadata. + +## Where it fits + +| Kind | Object or value | Identity | A missing entry | How to remove | +|---|---|---|---|---| +| BillingFeature | object | name | plan fails | cannot be removed | + +BillingFeature is an object, like BillingProduct and BillingPlan. Every feature on the server +must be in the file, and a missing one fails the plan. + +A feature cannot be deleted or retired. There is no delete API and no inactive state. So +`delete: true` is rejected, and a feature must stay in the file for as long as it exists on the +server. `frontier export billingfeature` lists every feature, so a file can start from it. + +## The kind + +An entry is a feature `{name, title, metadata}`. + +- **Name.** The name is the identity. It is matched exactly as the server stores it, including + case and spaces. Two server features whose names differ only by case are two entries. A file + name that matches a server feature only by case fails the plan, so a typo is not created as a + new feature. A name made only of spaces is rejected. +- **Title and metadata.** Both follow rule 2. An omitted title is empty. Omitted metadata is + `{}`. Metadata is one whole map: a file that drops a key removes it. +- **Product links.** Product links are owned by the BillingProduct kind. An update keeps the + current links, and export never writes them. A new feature is created with no links. +- **Order.** In one file, BillingFeature must come before BillingProduct, because a product + lists features by name. +- **Out of scope.** A feature with a name shorter than three characters cannot be sent through + the API, so it is left out of the plan and the export. + +## Server-side change + +Rule 2 needs every default to be reachable through the API. Before this change, `UpdateFeature` +kept the old title and metadata when the request sent them empty. Now `UpdateFeature` writes the +title, metadata and product links exactly as sent, and finds the feature by name. Other paths +that save features, like creating a product or a plan, keep their old merge behavior. + +The CLI and the server are expected to run the same version. An older server keeps the old +values when a file clears them, and the plan keeps showing the same update.