Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ed55cea
chore(proto): pin proton with plan write APIs on AdminService and reg…
rohilsurana Jul 31, 2026
2ffb9b2
feat(billing): list plans in any state with a StateAll filter sentinel
rohilsurana Jul 31, 2026
bebac31
feat(billing): add UpdatePlan to the plan service and handler interface
rohilsurana Jul 31, 2026
d0277ec
feat(billing): serve plan writes and ListAllPlans on the admin handler
rohilsurana Jul 31, 2026
aa670ec
feat(server): require super user for the AdminService plan RPCs
rohilsurana Jul 31, 2026
fbc00a2
test(e2e): create plans through the admin client
rohilsurana Jul 31, 2026
4053b0b
chore(proto): re-pin proton for UpdatePlanRequestBody and stricter pl…
rohilsurana Aug 3, 2026
e7ce351
feat(billing): update plans via a dedicated body and return 404 for a…
rohilsurana Aug 3, 2026
538f979
fix(billing): stop coercing plan state on update, default new plans t…
rohilsurana Aug 3, 2026
3bd779d
test(e2e): require state on create, cover UpdatePlan and ListAllPlans
rohilsurana Aug 3, 2026
939f608
chore(proto): re-pin proton for inactive plan state enum
rohilsurana Aug 3, 2026
1bc8d72
fix(billing): keep existing plan state when the seed file omits it
rohilsurana Aug 3, 2026
5e45fa2
test(billing): use inactive instead of disabled for plan state
rohilsurana Aug 3, 2026
b4bb7f0
fix(billing): read product behavior from the behavior column in plan …
rohilsurana Aug 4, 2026
bcee8ce
feat(billing): add plan state constants and an IsInactive helper
rohilsurana Aug 4, 2026
862512c
fix(billing): keep existing subscriptions resolving on inactive plans…
rohilsurana Aug 4, 2026
73729bc
feat(billing): block new checkouts onto inactive plans
rohilsurana Aug 4, 2026
fca2d2d
fix(billing): map the inactive-plan rejection to a client error inste…
rohilsurana Aug 4, 2026
4440ebe
refactor(billing): honor StateAll in the plain List and use plan stat…
rohilsurana Aug 4, 2026
d16d31c
test(billing): cover the inactive-plan gate and the UpdatePlan error …
rohilsurana Aug 4, 2026
6c08d22
test(e2e): assert UpdatePlan leaves name/interval/products intact and…
rohilsurana Aug 4, 2026
8a2aece
chore(proto): re-pin to the merged proton commit for the plan admin APIs
rohilsurana Aug 4, 2026
e37e7d7
refactor(billing): consolidate the inactive-plan error into one plan …
rohilsurana Aug 6, 2026
d67a53e
perf(billing): reuse the updated plan row instead of re-fetching in U…
rohilsurana Aug 6, 2026
a490837
refactor(billing): drop the plan repository state default; callers pa…
rohilsurana Aug 6, 2026
81db4bc
refactor(billing): share the plan-listing body between ListPlans and …
rohilsurana Aug 6, 2026
b805c5d
test(billing): cover the checkout inactive-plan rejection
rohilsurana Aug 6, 2026
cd283cd
docs(billing): explain why Apply skips the inactive-plan gate that Cr…
rohilsurana Aug 6, 2026
6d7313c
test(e2e): show UpdatePlan clears the fields left out of the body
rohilsurana Aug 6, 2026
66582bd
feat(billing): don't auto-onboard new orgs onto a retired default plan
rohilsurana Aug 6, 2026
921b8bd
feat(billing): reject inactive plans in checkout.Apply (delegated che…
rohilsurana Aug 6, 2026
35004d5
test(billing): assert an inactive plan maps to FailedPrecondition
rohilsurana Aug 6, 2026
0ee076f
fix(billing): reject an empty plan interval at the store create path
rohilsurana Aug 7, 2026
9646cbc
fix(billing): reject negative plan credits and trial days at the stor…
rohilsurana Aug 7, 2026
File filter

Filter by extension

Filter by extension

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

admin-app:
@echo " > generating admin build"
Expand Down
4 changes: 4 additions & 0 deletions billing/checkout/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"time"

"github.com/raystack/frontier/billing/plan"
"github.com/raystack/frontier/pkg/metadata"
)

Expand All @@ -26,6 +27,9 @@ var (
ErrInvalidDetail = errors.New("invalid checkout detail")
ErrKycCompleted = errors.New("organization kyc completed")
ErrAlreadySubscribed = errors.New("already subscribed to the plan")
// ErrPlanInactive aliases the single sentinel in billing/plan; the local
// alias lets this package reference it where the identifier `plan` is shadowed.
ErrPlanInactive = plan.ErrPlanInactive
)

type Checkout struct {
Expand Down
10 changes: 10 additions & 0 deletions billing/checkout/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
return Checkout{}, ErrAlreadySubscribed
}

// a retired (inactive) plan is closed to new subscriptions
if plan.IsInactive() {
return Checkout{}, fmt.Errorf("plan %q: %w", plan.Name, ErrPlanInactive)
}

// create subscription items
var subsItems []*stripe.CheckoutSessionLineItemParams

Expand Down Expand Up @@ -897,6 +902,11 @@ func (s *Service) Apply(ctx context.Context, ch Checkout) (*subscription.Subscri
return nil, nil, ErrAlreadySubscribed
}

// a retired (inactive) plan is closed to new subscriptions
if plan.IsInactive() {
return nil, nil, fmt.Errorf("plan %q: %w", plan.Name, ErrPlanInactive)
}

if err := s.cancelTrialingSubscription(ctx, ch.CustomerID, ch.PlanID); err != nil {
return nil, nil, err
}
Expand Down
83 changes: 83 additions & 0 deletions billing/checkout/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package checkout

import (
"context"
"io"
"log/slog"
"testing"

"github.com/raystack/frontier/billing/customer"
"github.com/raystack/frontier/billing/plan"
"github.com/raystack/frontier/billing/subscription"
"github.com/raystack/frontier/core/authenticate"
"github.com/stretchr/testify/require"
)

// The fakes embed the service interfaces so only the methods Create reaches
// before the inactive-plan gate need real behavior; any other call would panic,
// which keeps the test honest about what the gate depends on.
type fakeCustomerSvc struct{ CustomerService }

func (fakeCustomerSvc) RegisterToProviderIfRequired(_ context.Context, id string) (customer.Customer, error) {
return customer.Customer{ID: id, OrgID: "org-1"}, nil
}

func (fakeCustomerSvc) GetByID(_ context.Context, id string) (customer.Customer, error) {
// a non-empty ProviderID means the customer is not offline, so Apply runs the
// plan branch and reaches the inactive-plan gate.
return customer.Customer{ID: id, OrgID: "org-1", ProviderID: "cus_test"}, nil
}

type fakeAuthnSvc struct{ AuthnService }

func (fakeAuthnSvc) GetPrincipal(_ context.Context, _ ...authenticate.ClientAssertion) (authenticate.Principal, error) {
return authenticate.Principal{}, nil
}

type fakePlanSvc struct {
PlanService
p plan.Plan
}

func (f fakePlanSvc) GetByID(_ context.Context, _ string) (plan.Plan, error) {
return f.p, nil
}

type fakeSubscriptionSvc struct{ SubscriptionService }

func (fakeSubscriptionSvc) List(_ context.Context, _ subscription.Filter) ([]subscription.Subscription, error) {
return nil, nil
}

// A retired (inactive) plan is closed to new checkouts: Create must reject it
// with ErrPlanInactive instead of building a subscription. The subscription
// ChangePlan path has its own test; this covers the checkout path.
func TestService_Create_RejectsInactivePlan(t *testing.T) {
s := &Service{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
customerService: fakeCustomerSvc{},
authnService: fakeAuthnSvc{},
planService: fakePlanSvc{p: plan.Plan{ID: "plan-1", Name: "retired", State: plan.StateInactive}},
subscriptionService: fakeSubscriptionSvc{},
}

_, err := s.Create(context.Background(), Checkout{CustomerID: "cust-1", PlanID: "plan-1"})

require.ErrorIs(t, err, plan.ErrPlanInactive)
}

// Apply (the delegated/direct path behind DelegatedCheckout) must also reject a
// retired plan, so an admin assignment cannot land a new subscription on one.
func TestService_Apply_RejectsInactivePlan(t *testing.T) {
s := &Service{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
customerService: fakeCustomerSvc{},
authnService: fakeAuthnSvc{},
planService: fakePlanSvc{p: plan.Plan{ID: "plan-1", Name: "retired", State: plan.StateInactive}},
subscriptionService: fakeSubscriptionSvc{},
}

_, _, err := s.Apply(context.Background(), Checkout{CustomerID: "cust-1", PlanID: "plan-1"})

require.ErrorIs(t, err, plan.ErrPlanInactive)
}
15 changes: 15 additions & 0 deletions billing/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,23 @@ var (
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidName = errors.New("plan name is invalid")
ErrInvalidDetail = errors.New("invalid plan detail")
// ErrPlanInactive is the single sentinel for "a retired plan cannot take a new
// subscription". checkout and subscription both surface it, so a handler maps
// one error to one client code.
ErrPlanInactive = errors.New("plan is inactive and cannot be subscribed to")
)

const (
StateActive = "active"
StateInactive = "inactive"
)

// IsInactive reports whether the plan is retired: hidden from ListPlans and
// closed to new subscriptions. Existing subscriptions on it keep working.
func (p Plan) IsInactive() bool {
return p.State == StateInactive
}

// Plan is a collection of products
// it is a logical grouping of products and doesn't have
// a corresponding billing engine entity
Expand Down
19 changes: 19 additions & 0 deletions billing/plan/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,22 @@ func TestPlan_IsFree(t *testing.T) {
})
}
}

func TestPlan_IsInactive(t *testing.T) {
tests := []struct {
name string
state string
want bool
}{
{name: "inactive is inactive", state: "inactive", want: true},
{name: "active is not inactive", state: "active", want: false},
{name: "empty state is not inactive", state: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (plan.Plan{State: tt.state}).IsInactive(); got != tt.want {
t.Errorf("IsInactive() = %v, want %v", got, tt.want)
}
})
}
}
33 changes: 31 additions & 2 deletions billing/plan/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ func (s Service) Create(ctx context.Context, p Plan) (Plan, error) {
return s.planRepository.Create(ctx, p)
}

// UpdatePlan updates a plan's own fields: title, description, credits, trial
// days, state, and metadata. A plan's products are managed through UpsertPlans,
// so they are left untouched here. The plan is looked up by id or name.
func (s Service) UpdatePlan(ctx context.Context, p Plan) (Plan, error) {
existing, err := s.GetByID(ctx, p.ID)
if err != nil {
return Plan{}, err
}
existing.Title = p.Title
existing.Description = p.Description
existing.OnStartCredits = p.OnStartCredits
existing.TrialDays = p.TrialDays
existing.State = p.State
existing.Metadata = p.Metadata
updated, err := s.planRepository.UpdateByName(ctx, existing)
if err != nil {
return Plan{}, err
}
// UpdateByName returns the updated row, and an update never touches products,
// so reuse the ones already loaded instead of re-fetching and re-enriching.
updated.Products = existing.Products
return updated, nil
}

func (s Service) GetByID(ctx context.Context, id string) (Plan, error) {
var fetchedPlan Plan
var err error
Expand Down Expand Up @@ -280,7 +304,12 @@ func (s Service) UpsertPlans(ctx context.Context, planFile File) error {
} else if err != nil {
return err
} else {
// update plan
// update plan; the plan file may omit state on this seed path, so
// keep the existing plan's state rather than blanking it
state := planToCreate.State
if state == "" {
state = planOb.State
}
if _, err = s.planRepository.UpdateByName(ctx, Plan{
ID: planOb.ID,
Name: planToCreate.Name,
Expand All @@ -289,7 +318,7 @@ func (s Service) UpsertPlans(ctx context.Context, planFile File) error {
Description: planToCreate.Description,
TrialDays: planToCreate.TrialDays,
Metadata: planToCreate.Metadata,
State: planToCreate.State,
State: state,
}); err != nil {
return err
}
Expand Down
9 changes: 9 additions & 0 deletions billing/subscription/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,11 @@ func (s *Service) ChangePlan(ctx context.Context, id string, changeRequest Chang
return change, ErrAlreadyOnSamePlan
}

// cannot move a subscription onto a retired (inactive) plan; moving off one is allowed
if planObj.IsInactive() {
return change, fmt.Errorf("plan %q: %w", planObj.Name, plan.ErrPlanInactive)
}

// check if schedule exists
stripeSubscription, stripeSchedule, err := s.createOrGetSchedule(ctx, sub)
if err != nil {
Expand Down Expand Up @@ -1004,6 +1009,8 @@ func (s *Service) findPlanByStripeSubscription(ctx context.Context, stripeSubscr
plans, err := s.planService.List(ctx, plan.Filter{
IDs: productPlanIDs,
Interval: interval,
// no State filter: resolve an existing subscription's plan regardless of
// state, so an inactive (retired) plan still resolves for its subscribers.
})
if err != nil {
return plan.Plan{}, err
Expand Down Expand Up @@ -1040,6 +1047,8 @@ func (s *Service) findPlanByStripePhase(ctx context.Context, stripePhase *stripe
plans, err := s.planService.List(ctx, plan.Filter{
IDs: productPlanIDs,
Interval: interval,
// no State filter: resolve an existing subscription's plan regardless of
// state, so an inactive (retired) plan still resolves for its subscribers.
})
if err != nil {
return plan.Plan{}, err
Expand Down
17 changes: 17 additions & 0 deletions billing/subscription/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,23 @@ func TestService_ChangePlan(t *testing.T) {
},
wantErr: errors.New("plan not found"),
},
{
name: "should return error if the target plan is inactive",
id: "test-id",
change: subscription.ChangeRequest{
PlanID: "new-plan",
Immediate: true,
},
setup: func(r *mocks.Repository, p *mocks.PlanService, c *mocks.CustomerService, o *mocks.OrganizationService) {
r.EXPECT().GetByID(mock.Anything, "test-id").Return(subscription.Subscription{
ID: "test-id",
PlanID: "old-plan",
State: subscription.StateActive.String(),
}, nil)
p.EXPECT().GetByID(mock.Anything, "new-plan").Return(plan.Plan{ID: "new-plan", State: "inactive"}, nil)
},
wantErr: plan.ErrPlanInactive,
},
}

for _, tt := range tests {
Expand Down
12 changes: 11 additions & 1 deletion core/event/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import (
"github.com/raystack/frontier/core/organization"
)

var ErrDefaultPlanNotFree = errors.New("default plan is not free")
var (
ErrDefaultPlanNotFree = errors.New("default plan is not free")
ErrDefaultPlanInactive = errors.New("default plan is inactive")
)

type CheckoutService interface {
Apply(ctx context.Context, ch checkout.Checkout) (*subscription.Subscription, *product.Product, error)
Expand Down Expand Up @@ -156,6 +159,13 @@ func (p *Service) EnsureDefaultPlan(ctx context.Context, orgID string) error {
return fmt.Errorf("failed to get default plan: %w", err)
}

// a retired (inactive) default plan is config drift: do not silently
// onboard new orgs onto it. Fail loudly so the misconfiguration surfaces
// (the listener logs it); the org already exists, so no signup is lost.
if defaultPlan.IsInactive() {
return ErrDefaultPlanInactive
}

if !defaultPlan.IsFree() {
return ErrDefaultPlanNotFree
}
Expand Down
28 changes: 28 additions & 0 deletions core/event/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,34 @@ func TestEnsureDefaultPlan(t *testing.T) {
return service
},
},
{
name: "return error if defaultPlan is inactive",
args: args{
ctx: ctx,
orgID: "",
},
wantErr: ErrDefaultPlanInactive,
setup: func() *Service {
billingConf, checkoutService, customerService, orgService, planService, userService, membershipService, roleService, subsService, creditService, invoiceService := mockService(t)
service := NewService(*billingConf, orgService, checkoutService, customerService, planService, userService, membershipService, roleService, subsService, creditService, invoiceService)

customerService.On("List", ctx, customer.Filter{}).Return(nil, nil).Once()
org := organization.Organization{ID: "org_1", Title: "org_title"}
orgService.On("GetRaw", ctx, "").Return(org, nil).Once()
expectAdminLookup(ctx, roleService, membershipService, userService, "org_1", []user.User{{ID: "admin-1", Email: "email@example.com"}})
customerService.On("Create", ctx, customer.Customer{
OrgID: "org_1",
Name: getCustomerName(org),
Email: "email@example.com",
Currency: "USD",
Metadata: map[string]any{
"auto_created": "true",
}}, false).Return(customer.Customer{ID: "cid_1"}, nil).Once()
planService.On("GetByID", ctx, "default_plan").
Return(plan.Plan{State: plan.StateInactive}, nil).Once()
return service
},
},
{
name: "return error if checkoutService.Apply returns error",
args: args{
Expand Down
3 changes: 3 additions & 0 deletions internal/api/v1beta1connect/billing_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/raystack/frontier/billing/checkout"
"github.com/raystack/frontier/billing/customer"
billingerrors "github.com/raystack/frontier/billing/errors"
"github.com/raystack/frontier/billing/plan"
"github.com/raystack/frontier/billing/product"
"github.com/raystack/frontier/billing/subscription"
)
Expand Down Expand Up @@ -72,6 +73,8 @@ func mapBillingErrorCode(err error) *connect.Error {
return connect.NewError(connect.CodeNotFound, ErrCustomerNotFound)
case errors.Is(err, checkout.ErrAlreadySubscribed):
return connect.NewError(connect.CodeAlreadyExists, checkout.ErrAlreadySubscribed)
case errors.Is(err, plan.ErrPlanInactive):
return connect.NewError(connect.CodeFailedPrecondition, plan.ErrPlanInactive)
case errors.Is(err, product.ErrProductNotFound):
return connect.NewError(connect.CodeNotFound, product.ErrProductNotFound)
case errors.Is(err, product.ErrFeatureNotFound):
Expand Down
7 changes: 7 additions & 0 deletions internal/api/v1beta1connect/billing_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/raystack/frontier/billing/checkout"
"github.com/raystack/frontier/billing/customer"
billingerrors "github.com/raystack/frontier/billing/errors"
"github.com/raystack/frontier/billing/plan"
"github.com/raystack/frontier/billing/product"
"github.com/raystack/frontier/billing/subscription"
)
Expand Down Expand Up @@ -118,6 +119,12 @@ func TestMapBillingError(t *testing.T) {
wantCode: connect.CodeAlreadyExists,
wantMsg: checkout.ErrAlreadySubscribed.Error(),
},
{
name: "inactive plan is rejected",
err: fmt.Errorf("CreateCheckout.Create: %w", plan.ErrPlanInactive),
wantCode: connect.CodeFailedPrecondition,
wantMsg: plan.ErrPlanInactive.Error(),
},
{
name: "product not found",
err: fmt.Errorf("GetProduct.GetByID: product_id=abc: %w", product.ErrProductNotFound),
Expand Down
Loading
Loading