Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 8 additions & 6 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
54 changes: 50 additions & 4 deletions docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
10 changes: 6 additions & 4 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

```
Expand Down Expand Up @@ -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.

Expand Down
58 changes: 58 additions & 0 deletions docs/rfcs/0001-declarative-reconcile-addendum-billingfeature.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +26 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor · Functional Correctness · Quick win

Exclude out-of-scope features from the full-list requirement.

Features with names shorter than three characters are omitted from export. If a user follows this requirement and adds one to the file, reconciliation fails. State that only in-scope features must be listed, and that short-name entries are rejected.


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.
157 changes: 157 additions & 0 deletions internal/reconcile/billingfeature.go
Original file line number Diff line number Diff line change
@@ -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 })
}
Loading
Loading