From d0622fbc83290632b01fd3b06641622d7669d7f5 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 5 Aug 2026 11:49:49 +0530 Subject: [PATCH 1/5] feat(reconcile): add the BillingPlan spec and diff logic --- internal/reconcile/billingplan.go | 258 +++++++++++++++++++++++++ internal/reconcile/billingplan_test.go | 177 +++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 internal/reconcile/billingplan.go create mode 100644 internal/reconcile/billingplan_test.go diff --git a/internal/reconcile/billingplan.go b/internal/reconcile/billingplan.go new file mode 100644 index 0000000000..e6eb0dc04f --- /dev/null +++ b/internal/reconcile/billingplan.go @@ -0,0 +1,258 @@ +package reconcile + +import ( + "fmt" + "sort" + "strings" + + "google.golang.org/protobuf/types/known/structpb" +) + +// KindBillingPlan is the desired-state document kind for billing plans. +const KindBillingPlan = "BillingPlan" + +// BillingPlanSpec is one desired plan. The name is the identity and never +// changes. A plan groups products (referenced by name; the products themselves +// are managed by the BillingProduct kind). Title, description, on_start_credits, +// trial_days, and state are converged through UpdatePlan. Interval and the +// product set are create-only: UpdatePlan cannot change them, so a change to +// either fails the plan. A plan cannot be deleted through the API, so the delete +// flag is rejected. Metadata is out of scope: it is not stated in the file, not +// diffed, and not exported, but it is preserved on update (see the reconciler). +type BillingPlanSpec struct { + Name string `yaml:"name"` + Title string `yaml:"title,omitempty"` + Description string `yaml:"description,omitempty"` + Interval string `yaml:"interval,omitempty"` + OnStartCredits int64 `yaml:"on_start_credits,omitempty"` + TrialDays int64 `yaml:"trial_days,omitempty"` + State string `yaml:"state,omitempty"` + Products []BillingPlanProductRef `yaml:"products,omitempty"` + Delete bool `yaml:"delete,omitempty"` +} + +// BillingPlanProductRef names a product that belongs to the plan. The product is +// managed by the BillingProduct kind; the plan only references it by name. +type BillingPlanProductRef struct { + Name string `yaml:"name"` +} + +// currentBillingPlan is one plan as returned by ListAllPlans, including inactive +// ones. Products holds the names of the products attached to the plan. Metadata +// is carried so an update can re-send it; the plan kind does not otherwise manage +// metadata. +type currentBillingPlan struct { + ID string + Name string + Title string + Description string + Interval string + OnStartCredits int64 + TrialDays int64 + State string + Products []string + Metadata *structpb.Struct +} + +// billingPlanOp is a single planned change. spec carries the whole desired plan; +// id is set for an update, and metadata holds the current plan's metadata so the +// update can preserve it (UpdatePlan is a full write of the fields it carries). +type billingPlanOp struct { + action opAction + spec BillingPlanSpec + id string + detail string + metadata *structpb.Struct +} + +func (o billingPlanOp) String() string { + if o.action == opUpdate { + return fmt.Sprintf("update plan %s (%s)", o.spec.Name, o.detail) + } + products := "no products" + if len(o.spec.Products) > 0 { + names := make([]string, 0, len(o.spec.Products)) + for _, p := range o.spec.Products { + names = append(names, p.Name) + } + products = "products: " + strings.Join(uniqueSorted(names), ", ") + } + return fmt.Sprintf("add plan %s [%s]", o.spec.Name, products) +} + +// validateBillingPlanSpec rejects entries the flow cannot manage without touching +// the server: a missing or too-short name, a missing title, a delete flag (plans +// cannot be removed through the API), and a duplicate product reference. It does +// not re-list the valid intervals or states; the server rejects a bad value +// through its validate interceptor, checked in the reconciler. +func validateBillingPlanSpec(s BillingPlanSpec) error { + name := strings.TrimSpace(s.Name) + if name == "" { + return fmt.Errorf("plan name is required") + } + // the server requires a plan name of at least three characters, so a shorter + // one would fail at apply; reject it here instead. + if len(name) < 3 { + return fmt.Errorf("plan name %q must be at least three characters", name) + } + // title is written in full, so an omitted one would plan a reset toward empty; + // require it up front. A plan should have a title. + if strings.TrimSpace(s.Title) == "" { + return fmt.Errorf("plan %q must have a title", name) + } + if s.Delete { + return fmt.Errorf("plan %q cannot be deleted: there is no plan delete API; set its state to inactive instead, or remove the entry and archive it by hand", s.Name) + } + + seenProduct := map[string]struct{}{} + for _, p := range s.Products { + productName := strings.ToLower(strings.TrimSpace(p.Name)) + if productName == "" { + return fmt.Errorf("plan %q references a product with no name", s.Name) + } + if _, dup := seenProduct[productName]; dup { + return fmt.Errorf("plan %q references product %q more than once", s.Name, productName) + } + seenProduct[productName] = struct{}{} + } + return nil +} + +// normalizeBillingPlanSpecs trims each plan name, validates every entry, and +// rejects a plan listed more than once, so Validate and diff work from identical, +// deduplicated input. +func normalizeBillingPlanSpecs(specs []BillingPlanSpec) ([]BillingPlanSpec, error) { + seen := map[string]struct{}{} + out := make([]BillingPlanSpec, 0, len(specs)) + for _, s := range specs { + s.Name = strings.TrimSpace(s.Name) + if err := validateBillingPlanSpec(s); err != nil { + return nil, fmt.Errorf("invalid billing plan spec %q: %w", s.Name, err) + } + key := strings.ToLower(s.Name) + if _, dup := seen[key]; dup { + return nil, fmt.Errorf("plan %q is listed more than once", s.Name) + } + seen[key] = struct{}{} + out = append(out, s) + } + return out, nil +} + +// diffBillingPlans returns the ops that make the current plans match the desired +// spec. The name is the identity: a plan not on the server is added, a plan whose +// managed fields differ is updated, and a plan on the server that the file does +// not list fails the plan, since a plan cannot be removed through the API (retire +// it with state instead). +func diffBillingPlans(desired []BillingPlanSpec, current []currentBillingPlan) ([]billingPlanOp, error) { + desired, err := normalizeBillingPlanSpecs(desired) + if err != nil { + return nil, err + } + + byName := make(map[string]currentBillingPlan, len(current)) + for _, c := range current { + byName[strings.ToLower(c.Name)] = c + } + + seen := map[string]struct{}{} + var adds, updates []billingPlanOp + for _, s := range desired { + key := strings.ToLower(s.Name) + seen[key] = struct{}{} + + cur, exists := byName[key] + if !exists { + adds = append(adds, billingPlanOp{action: opAdd, spec: s}) + continue + } + changes, err := billingPlanChanges(s, cur) + if err != nil { + return nil, err + } + if len(changes) > 0 { + updates = append(updates, billingPlanOp{ + action: opUpdate, + spec: s, + id: cur.ID, + detail: strings.Join(changes, ", "), + metadata: cur.Metadata, + }) + } + } + + var unaccounted []string + for _, c := range current { + if _, ok := seen[strings.ToLower(c.Name)]; !ok { + unaccounted = append(unaccounted, c.Name) + } + } + if len(unaccounted) > 0 { + sort.Strings(unaccounted) + return nil, fmt.Errorf("plans exist on the server but are not in the file: %s; a plan cannot be removed through the API, so add it back to the file (set its state to inactive to retire it)", strings.Join(unaccounted, ", ")) + } + + return append(adds, updates...), nil +} + +// billingPlanChanges lists the managed fields that differ between a desired plan +// and its current state, matching what UpdatePlan will apply. Title, description, +// on_start_credits, trial_days, and state are written in full, so any difference +// is a plannable change. Interval and the product set are create-only: UpdatePlan +// does not touch them, so a change to either fails the plan. An empty result means +// the plan already matches and needs no update. +func billingPlanChanges(s BillingPlanSpec, cur currentBillingPlan) ([]string, error) { + // interval is create-only: the server sets it at create and UpdatePlan cannot + // change it. A file that asks to change it cannot apply, so fail the plan. + if !strings.EqualFold(strings.TrimSpace(s.Interval), strings.TrimSpace(cur.Interval)) { + return nil, fmt.Errorf("plan %q interval cannot change from %q to %q after creation; create a new plan to change the interval", s.Name, cur.Interval, s.Interval) + } + // the product set is create-only too: UpdatePlan does not change a plan's + // products, so a change to them cannot apply. + if !billingPlanProductSetsEqual(s.Products, cur.Products) { + return nil, fmt.Errorf("plan %q products cannot change after creation; UpdatePlan does not change a plan's products, so create a new plan", s.Name) + } + + var changes []string + if s.Title != cur.Title { + changes = append(changes, "title") + } + if s.Description != cur.Description { + changes = append(changes, "description") + } + if s.OnStartCredits != cur.OnStartCredits { + changes = append(changes, "on_start_credits") + } + if s.TrialDays != cur.TrialDays { + changes = append(changes, "trial_days") + } + if normalizeBillingPlanState(s.State) != normalizeBillingPlanState(cur.State) { + changes = append(changes, "state") + } + return changes, nil +} + +// normalizeBillingPlanState maps an empty state to "active" (the server default) +// and lowercases the value, so a plan that omits state or writes it in another +// case compares equal to the stored one. +func normalizeBillingPlanState(state string) string { + s := strings.ToLower(strings.TrimSpace(state)) + if s == "" { + return "active" + } + return s +} + +// billingPlanProductSetsEqual reports whether the desired product references name +// the same set as the current plan, case-insensitively and order-independently. +func billingPlanProductSetsEqual(desired []BillingPlanProductRef, current []string) bool { + d := make([]string, 0, len(desired)) + for _, p := range desired { + d = append(d, strings.ToLower(strings.TrimSpace(p.Name))) + } + c := make([]string, 0, len(current)) + for _, name := range current { + c = append(c, strings.ToLower(strings.TrimSpace(name))) + } + return stringSetsEqual(uniqueSorted(d), uniqueSorted(c)) +} diff --git a/internal/reconcile/billingplan_test.go b/internal/reconcile/billingplan_test.go new file mode 100644 index 0000000000..b8a236e930 --- /dev/null +++ b/internal/reconcile/billingplan_test.go @@ -0,0 +1,177 @@ +package reconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" +) + +// newBillingPlan returns a fresh valid plan spec for tests to mutate. +func newBillingPlan() BillingPlanSpec { + return BillingPlanSpec{ + Name: "starter", + Title: "Starter", + Description: "Starter plan", + Interval: "month", + OnStartCredits: 100, + TrialDays: 14, + State: "active", + Products: []BillingPlanProductRef{{Name: "starter_product"}}, + } +} + +// curStarter is the current server state matching newBillingPlan. +func curStarter() currentBillingPlan { + return currentBillingPlan{ + ID: "p1", + Name: "starter", + Title: "Starter", + Description: "Starter plan", + Interval: "month", + OnStartCredits: 100, + TrialDays: 14, + State: "active", + Products: []string{"starter_product"}, + } +} + +func TestValidateBillingPlanSpec(t *testing.T) { + cases := []struct { + name string + mutate func(*BillingPlanSpec) + wantErr string + }{ + {"valid", func(*BillingPlanSpec) {}, ""}, + {"missing name", func(s *BillingPlanSpec) { s.Name = "" }, "name is required"}, + {"name too short", func(s *BillingPlanSpec) { s.Name = "ab" }, "at least three characters"}, + {"empty title", func(s *BillingPlanSpec) { s.Title = "" }, "must have a title"}, + {"delete is rejected", func(s *BillingPlanSpec) { s.Delete = true }, "cannot be deleted"}, + {"empty product name", func(s *BillingPlanSpec) { s.Products[0].Name = "" }, "product with no name"}, + {"duplicate product", func(s *BillingPlanSpec) { + s.Products = []BillingPlanProductRef{{Name: "p"}, {Name: "p"}} + }, "more than once"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := newBillingPlan() + c.mutate(&s) + err := validateBillingPlanSpec(s) + if c.wantErr == "" { + assert.NoError(t, err) + return + } + assert.ErrorContains(t, err, c.wantErr) + }) + } +} + +func TestDiffBillingPlans(t *testing.T) { + t.Run("adds a plan the server does not have", func(t *testing.T) { + desired := []BillingPlanSpec{ + newBillingPlan(), + {Name: "pro", Title: "Pro", Interval: "year", State: "active", Products: []BillingPlanProductRef{{Name: "pro_product"}}}, + } + ops, err := diffBillingPlans(desired, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, opAdd, ops[0].action) + assert.Equal(t, "pro", ops[0].spec.Name) + } + }) + + t.Run("is a no-op when the plan already matches", func(t *testing.T) { + ops, err := diffBillingPlans([]BillingPlanSpec{newBillingPlan()}, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("updates a plan whose title differs", func(t *testing.T) { + s := newBillingPlan() + s.Title = "Renamed" + ops, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, opUpdate, ops[0].action) + assert.Equal(t, "p1", ops[0].id) + assert.Contains(t, ops[0].detail, "title") + } + }) + + t.Run("updates when the state changes to inactive", func(t *testing.T) { + s := newBillingPlan() + s.State = "inactive" + ops, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Contains(t, ops[0].detail, "state") + } + }) + + t.Run("does not plan a change for a state case difference", func(t *testing.T) { + s := newBillingPlan() + s.State = "Active" // server stored "active" + ops, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("treats an empty file state as active", func(t *testing.T) { + s := newBillingPlan() + s.State = "" // omitted; the server default is active + ops, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("carries the current metadata onto an update so it is preserved", func(t *testing.T) { + cur := curStarter() + cur.Metadata, _ = structpb.NewStruct(map[string]any{"plan_group_id": "starter"}) + s := newBillingPlan() + s.Title = "Renamed" + ops, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{cur}) + assert.NoError(t, err) + if assert.Len(t, ops, 1) { + assert.Equal(t, cur.Metadata, ops[0].metadata) + } + }) + + t.Run("fails the plan when the interval changes", func(t *testing.T) { + s := newBillingPlan() + s.Interval = "year" + _, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.ErrorContains(t, err, "interval cannot change") + }) + + t.Run("fails the plan when the product set changes", func(t *testing.T) { + s := newBillingPlan() + s.Products = []BillingPlanProductRef{{Name: "different_product"}} + _, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.ErrorContains(t, err, "products cannot change") + }) + + t.Run("does not plan a change for a product-name case difference", func(t *testing.T) { + s := newBillingPlan() + s.Products = []BillingPlanProductRef{{Name: "Starter_Product"}} // server stored "starter_product" + ops, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + t.Run("fails when a server plan is missing from the file", func(t *testing.T) { + _, err := diffBillingPlans(nil, []currentBillingPlan{curStarter()}) + assert.ErrorContains(t, err, "not in the file") + }) + + t.Run("rejects a delete flag", func(t *testing.T) { + s := newBillingPlan() + s.Delete = true + _, err := diffBillingPlans([]BillingPlanSpec{s}, []currentBillingPlan{curStarter()}) + assert.ErrorContains(t, err, "cannot be deleted") + }) + + t.Run("rejects a plan listed more than once", func(t *testing.T) { + _, err := diffBillingPlans([]BillingPlanSpec{newBillingPlan(), newBillingPlan()}, []currentBillingPlan{curStarter()}) + assert.ErrorContains(t, err, "listed more than once") + }) +} From 925e9e8eae56f88353e088d992634d33fa86e1a8 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 5 Aug 2026 11:49:49 +0530 Subject: [PATCH 2/5] feat(reconcile): add the BillingPlan reconciler on the AdminService plan APIs --- internal/reconcile/billingplan_reconciler.go | 265 +++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 internal/reconcile/billingplan_reconciler.go diff --git a/internal/reconcile/billingplan_reconciler.go b/internal/reconcile/billingplan_reconciler.go new file mode 100644 index 0000000000..9ade6b840b --- /dev/null +++ b/internal/reconcile/billingplan_reconciler.go @@ -0,0 +1,265 @@ +package reconcile + +import ( + "context" + "fmt" + "sort" + "strings" + + "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" +) + +// BillingPlanAPI is the API subset the billing plan reconciler needs. Plans live +// on the AdminService: reads come from ListAllPlans, which returns every plan +// (active and inactive) with its products, and writes go through CreatePlan and +// UpdatePlan. CreatePlan associates the referenced products by name; UpdatePlan +// changes only a plan's own fields (title, description, credits, trial days, +// state, metadata). +type BillingPlanAPI interface { + ListAllPlans(context.Context, *connect.Request[frontierv1beta1.ListAllPlansRequest]) (*connect.Response[frontierv1beta1.ListAllPlansResponse], error) + CreatePlan(context.Context, *connect.Request[frontierv1beta1.CreatePlanRequest]) (*connect.Response[frontierv1beta1.CreatePlanResponse], error) + UpdatePlan(context.Context, *connect.Request[frontierv1beta1.UpdatePlanRequest]) (*connect.Response[frontierv1beta1.UpdatePlanResponse], error) +} + +// BillingPlanReconciler makes billing plans match the desired spec. The plan name +// is the identity; title, description, on_start_credits, trial_days, and state are +// converged through UpdatePlan. Interval and the product set are create-only, so a +// change to either fails the plan. A plan missing from the file fails the plan, +// because there is no API to remove one. +type BillingPlanReconciler struct { + client BillingPlanAPI + header string +} + +func NewBillingPlanReconciler(client BillingPlanAPI, header string) *BillingPlanReconciler { + return &BillingPlanReconciler{client: client, header: header} +} + +func (r *BillingPlanReconciler) Kind() string { return KindBillingPlan } + +func (r *BillingPlanReconciler) Validate(spec []byte) error { + var specs []BillingPlanSpec + if err := decodeSpec(spec, &specs); err != nil { + return fmt.Errorf("parse %s spec: %w", KindBillingPlan, err) + } + normalized, err := normalizeBillingPlanSpecs(specs) + if err != nil { + return err + } + // check every entry against the proto's own rules (the state enum, the + // interval enum, the non-negative bounds in buf.validate), server-free, so a + // bad value fails the whole file up front rather than only when an op happens + // to be planned for that entry. + for _, s := range normalized { + if err := validateBillingPlanRequest(billingPlanOp{action: opAdd, spec: s}); err != nil { + return err + } + } + return nil +} + +func (r *BillingPlanReconciler) Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) { + var specs []BillingPlanSpec + if err := decodeSpec(spec, &specs); err != nil { + return Report{}, fmt.Errorf("parse %s spec: %w", KindBillingPlan, err) + } + + current, outOfScope, err := r.fetchCurrent(ctx) + if err != nil { + return Report{}, err + } + if err := checkOutOfScopePlans(specs, outOfScope); err != nil { + return Report{}, err + } + + ops, err := diffBillingPlans(specs, current) + if err != nil { + return Report{}, err + } + + rep := Report{Kind: KindBillingPlan, DryRun: dryRun} + for _, op := range ops { + // validate the request the apply would send against the proto's own rules, + // so a value the server would reject fails the plan instead of the apply. + if err := validateBillingPlanRequest(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 +} + +// Export returns the current plans as a desired-state spec, sorted by name, with +// each plan's product references sorted too, so the output is stable. State is +// written (so an inactive plan round-trips and reconciling the export plans no +// change). Ids, timestamps, and metadata are not written: the first two are +// server-owned, and metadata is out of scope for this kind. +func (r *BillingPlanReconciler) Export(ctx context.Context) (any, error) { + current, _, err := r.fetchCurrent(ctx) + if err != nil { + return nil, err + } + sort.Slice(current, func(i, j int) bool { return current[i].Name < current[j].Name }) + + specs := make([]BillingPlanSpec, 0, len(current)) + for _, c := range current { + entry := BillingPlanSpec{ + Name: c.Name, + Title: c.Title, + Description: c.Description, + Interval: c.Interval, + OnStartCredits: c.OnStartCredits, + TrialDays: c.TrialDays, + State: c.State, + } + for _, name := range uniqueSorted(c.Products) { + entry.Products = append(entry.Products, BillingPlanProductRef{Name: name}) + } + specs = append(specs, entry) + } + return specs, nil +} + +func (r *BillingPlanReconciler) fetchCurrent(ctx context.Context) ([]currentBillingPlan, []string, error) { + // ListAllPlans returns every plan, active and inactive, in one response; it + // does not paginate. The "every plan must appear in the file" rule in + // diffBillingPlans relies on that: if the API ever paginates, a plan past the + // first page would look missing here and the plan would try to recreate it. + resp, err := r.client.ListAllPlans(ctx, authReq(&frontierv1beta1.ListAllPlansRequest{}, r.header)) + if err != nil { + return nil, nil, fmt.Errorf("list all plans: %w", err) + } + var current []currentBillingPlan + var outOfScope []string + for _, p := range resp.Msg.GetPlans() { + // a plan the kind cannot represent is out of scope: a name shorter than + // three characters or an empty title (the kind requires one). It is neither + // diffed nor exported, and a file that names it fails the plan. + if len(p.GetName()) < 3 || p.GetTitle() == "" { + outOfScope = append(outOfScope, p.GetName()) + continue + } + cur := currentBillingPlan{ + ID: p.GetId(), + Name: p.GetName(), + Title: p.GetTitle(), + Description: p.GetDescription(), + Interval: p.GetInterval(), + OnStartCredits: p.GetOnStartCredits(), + TrialDays: p.GetTrialDays(), + State: p.GetState(), + // metadata is out of scope: it is not diffed or exported, but it is + // carried so an update can re-send it, since UpdatePlan is a full write + // of the fields it carries and would otherwise clear it. + Metadata: p.GetMetadata(), + } + for _, prod := range p.GetProducts() { + cur.Products = append(cur.Products, prod.GetName()) + } + current = append(current, cur) + } + return current, outOfScope, nil +} + +// checkOutOfScopePlans fails the plan when the file names a plan the server holds +// but this kind cannot represent (an empty title or a name shorter than three +// characters). Such a plan is skipped in fetchCurrent, so without this check the +// diff would see the file entry as new and plan a create the apply would reject on +// the unique name; failing here keeps the plan honest. +func checkOutOfScopePlans(specs []BillingPlanSpec, outOfScope []string) error { + if len(outOfScope) == 0 { + return nil + } + 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("plan %q is out of scope for this kind: the server holds it with an empty title or 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 +} + +// validateBillingPlanRequest builds the request the apply would send and checks it +// against the proto's buf.validate rules. Reusing the proto's own rules means a +// bad state or interval, or a negative credit or trial-days value, is caught at +// plan time, and the check cannot drift from the server's, since both come from +// the same generated descriptors. +func validateBillingPlanRequest(op billingPlanOp) error { + var msg proto.Message + switch op.action { + case opAdd: + msg = &frontierv1beta1.CreatePlanRequest{Body: billingPlanCreateBody(op.spec)} + case opUpdate: + msg = &frontierv1beta1.UpdatePlanRequest{Id: op.id, Body: billingPlanUpdateBody(op.spec, op.metadata)} + default: + return fmt.Errorf("unknown op action %q", op.action) + } + if err := protovalidate.Validate(msg); err != nil { + return fmt.Errorf("plan %q: %w", op.spec.Name, err) + } + return nil +} + +func (r *BillingPlanReconciler) apply(ctx context.Context, op billingPlanOp) error { + switch op.action { + case opAdd: + _, err := r.client.CreatePlan(ctx, authReq(&frontierv1beta1.CreatePlanRequest{Body: billingPlanCreateBody(op.spec)}, r.header)) + return err + case opUpdate: + _, err := r.client.UpdatePlan(ctx, authReq(&frontierv1beta1.UpdatePlanRequest{Id: op.id, Body: billingPlanUpdateBody(op.spec, op.metadata)}, r.header)) + return err + default: + return fmt.Errorf("unknown op action %q", op.action) + } +} + +// billingPlanCreateBody builds the CreatePlan body. The whole desired plan is +// sent, including its product references by name; CreatePlan's upsert associates +// them. Metadata is out of scope, so it is not set on create. +func billingPlanCreateBody(s BillingPlanSpec) *frontierv1beta1.PlanRequestBody { + products := make([]*frontierv1beta1.Product, 0, len(s.Products)) + for _, p := range s.Products { + products = append(products, &frontierv1beta1.Product{Name: p.Name}) + } + return &frontierv1beta1.PlanRequestBody{ + Name: s.Name, + Title: s.Title, + Description: s.Description, + Interval: strings.ToLower(s.Interval), + OnStartCredits: s.OnStartCredits, + TrialDays: s.TrialDays, + State: strings.ToLower(s.State), + Products: products, + } +} + +// billingPlanUpdateBody builds the UpdatePlan body. It carries only the fields +// UpdatePlan can change; interval, name, and products are create-only. Metadata is +// out of scope, but UpdatePlan is a full write of the fields it carries, so the +// current metadata is re-sent to keep it rather than clear it. +func billingPlanUpdateBody(s BillingPlanSpec, metadata *structpb.Struct) *frontierv1beta1.UpdatePlanRequestBody { + return &frontierv1beta1.UpdatePlanRequestBody{ + Title: s.Title, + Description: s.Description, + OnStartCredits: s.OnStartCredits, + TrialDays: s.TrialDays, + State: strings.ToLower(s.State), + Metadata: metadata, + } +} From 8d6a29a4a77e8d328738d5c0c1fd9597ca7dd445 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 5 Aug 2026 11:49:49 +0530 Subject: [PATCH 3/5] feat(reconcile): register the BillingPlan kind --- cmd/reconcile.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/reconcile.go b/cmd/reconcile.go index c60552173a..4840444ca4 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -92,6 +92,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header), reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header), reconcile.KindBillingProduct: reconcile.NewBillingProductReconciler(api, header), + reconcile.KindBillingPlan: reconcile.NewBillingPlanReconciler(api, header), }, nil } From 8c72c6847662e3cde7f252be3a6330d073b2dce9 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 5 Aug 2026 14:19:17 +0530 Subject: [PATCH 4/5] docs(reconcile): document the BillingPlan kind --- cmd/reconcile.go | 15 +++++---- docs/content/docs/reconcile.mdx | 60 +++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 4840444ca4..24d3f368b1 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -24,13 +24,14 @@ 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), 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. + settings), Webhook (webhook endpoints), BillingProduct (billing products + and their prices), and BillingPlan (billing plans and the products they + bundle). 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 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 " to print the current state in this file format. `), diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx index 8cba87764f..be620dd018 100644 --- a/docs/content/docs/reconcile.mdx +++ b/docs/content/docs/reconcile.mdx @@ -302,6 +302,62 @@ spec: 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. +## The BillingPlan kind + +`BillingPlan` manages billing plans: a named bundle of products a customer subscribes to, at +one billing interval. The plan name is the identity and never changes. Plans live on the admin +API, so reconciling this kind needs a superuser token. + +```yaml +apiVersion: v1 +kind: BillingPlan +spec: + - name: standard_monthly + title: Standard (monthly) + description: The standard plan, billed each month + interval: month + on_start_credits: 500 + trial_days: 14 + state: active + products: + - name: standard_plan_product + - name: tokens + - name: standard_yearly + title: Standard (yearly) + interval: year + state: active + products: + - name: standard_plan_product +``` + +- The plan name is the identity and must be at least three characters. A `title` is required. + `title`, `description`, `on_start_credits`, `trial_days`, and `state` are the managed fields. + Each one states the whole desired value, so leaving it out resets it: an omitted + `description` clears it, and an omitted `on_start_credits` or `trial_days` sets it to zero. +- `state` is `active` or `inactive`, and an omitted state means `active`. An `inactive` plan + is retired: customers already on it keep it, but no one new can subscribe, a subscription on + it cannot renew, and it does not show up in the public plan list. The value is checked + against the API's own rules when the file is validated, up front, so a wrong value fails + before anything applies. +- `interval` and the product set are set only when the plan is created and cannot change + afterward. A file that asks to change either fails the plan. To change them, add a new plan + under a new name and retire the old one by setting it `inactive`. +- `products` lists the products in the plan by name. The products must already exist, so a + `BillingProduct` document that creates them should come first; a plan does not create its + own products. Order does not matter, and listing a product twice fails the plan. +- Metadata is out of scope for this kind: it is never set, changed, or exported here. An + update keeps whatever metadata the plan already holds. +- Every plan on the server must appear in the file. A plan that is missing fails the plan. + There is no API to remove a plan, so `delete: true` is rejected; retire a plan by setting it + `inactive` instead. The one exception is a plan this kind cannot represent, one with an empty + title or a name shorter than three characters. That plan 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 plan sorted by name, and each plan's products sorted by name, so + reconciling an export plans nothing. State is written, so an inactive plan round-trips. Ids, + timestamps, and metadata are server-owned or out of scope and not written; out-of-scope + plans are left out too. + ## Running it Log in as a superuser. The bootstrap service user exists for exactly this; its client id @@ -344,8 +400,8 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an ## More kinds -This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, `Webhook`, and -`BillingProduct`. The design and +This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, `Webhook`, +`BillingProduct`, and `BillingPlan`. 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 From 458969a378aaacbdf8ddfd577055f24e23a6dfbb Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 7 Aug 2026 11:00:44 +0530 Subject: [PATCH 5/5] fix(reconcile): default an omitted BillingPlan state to active before validation --- internal/reconcile/billingplan_reconciler.go | 15 ++++++----- internal/reconcile/billingplan_test.go | 26 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/internal/reconcile/billingplan_reconciler.go b/internal/reconcile/billingplan_reconciler.go index 9ade6b840b..af15050330 100644 --- a/internal/reconcile/billingplan_reconciler.go +++ b/internal/reconcile/billingplan_reconciler.go @@ -231,7 +231,9 @@ func (r *BillingPlanReconciler) apply(ctx context.Context, op billingPlanOp) err // billingPlanCreateBody builds the CreatePlan body. The whole desired plan is // sent, including its product references by name; CreatePlan's upsert associates -// them. Metadata is out of scope, so it is not set on create. +// them. An omitted state defaults to active (as the docs, the diff, and the store +// all treat it), so a file that leaves state out passes the proto's state enum. +// Metadata is out of scope, so it is not set on create. func billingPlanCreateBody(s BillingPlanSpec) *frontierv1beta1.PlanRequestBody { products := make([]*frontierv1beta1.Product, 0, len(s.Products)) for _, p := range s.Products { @@ -244,22 +246,23 @@ func billingPlanCreateBody(s BillingPlanSpec) *frontierv1beta1.PlanRequestBody { Interval: strings.ToLower(s.Interval), OnStartCredits: s.OnStartCredits, TrialDays: s.TrialDays, - State: strings.ToLower(s.State), + State: normalizeBillingPlanState(s.State), Products: products, } } // billingPlanUpdateBody builds the UpdatePlan body. It carries only the fields -// UpdatePlan can change; interval, name, and products are create-only. Metadata is -// out of scope, but UpdatePlan is a full write of the fields it carries, so the -// current metadata is re-sent to keep it rather than clear it. +// UpdatePlan can change; interval, name, and products are create-only. An omitted +// state defaults to active, the same as on create. Metadata is out of scope, but +// UpdatePlan is a full write of the fields it carries, so the current metadata is +// re-sent to keep it rather than clear it. func billingPlanUpdateBody(s BillingPlanSpec, metadata *structpb.Struct) *frontierv1beta1.UpdatePlanRequestBody { return &frontierv1beta1.UpdatePlanRequestBody{ Title: s.Title, Description: s.Description, OnStartCredits: s.OnStartCredits, TrialDays: s.TrialDays, - State: strings.ToLower(s.State), + State: normalizeBillingPlanState(s.State), Metadata: metadata, } } diff --git a/internal/reconcile/billingplan_test.go b/internal/reconcile/billingplan_test.go index b8a236e930..46920f865f 100644 --- a/internal/reconcile/billingplan_test.go +++ b/internal/reconcile/billingplan_test.go @@ -66,6 +66,32 @@ func TestValidateBillingPlanSpec(t *testing.T) { } } +// The docs and the diff both treat an omitted state as active, so a file that +// leaves state out is valid and must pass the up-front check. Before the fix the +// create and update bodies sent an empty state, which the proto's state enum +// rejected, so a valid file failed at Validate even though the diff and the store +// would have defaulted it to active. +func TestValidateBillingPlanRequest_DefaultsOmittedStateToActive(t *testing.T) { + s := newBillingPlan() + s.State = "" // omitted + + t.Run("create", func(t *testing.T) { + assert.NoError(t, validateBillingPlanRequest(billingPlanOp{action: opAdd, spec: s})) + }) + t.Run("update", func(t *testing.T) { + assert.NoError(t, validateBillingPlanRequest(billingPlanOp{action: opUpdate, id: "p1", spec: s})) + }) +} + +// Validate is the up-front, server-free check Run calls on every document before +// anything applies. A hand-written file that omits state relies on the documented +// default of active, so Validate must accept it. +func TestBillingPlanReconciler_Validate_AcceptsOmittedState(t *testing.T) { + spec := []byte("- name: standard_monthly\n title: Standard\n interval: month\n products:\n - name: prod_a\n") + r := NewBillingPlanReconciler(nil, "") + assert.NoError(t, r.Validate(spec)) +} + func TestDiffBillingPlans(t *testing.T) { t.Run("adds a plan the server does not have", func(t *testing.T) { desired := []BillingPlanSpec{