Skip to content
Merged
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
16 changes: 9 additions & 7 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind>" to print the current state in this file format.
`),
Expand Down Expand Up @@ -92,6 +93,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
}

Expand Down
60 changes: 58 additions & 2 deletions docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
258 changes: 258 additions & 0 deletions internal/reconcile/billingplan.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading