Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3f7da62
chore(proto): pin proton with plan write APIs on AdminService and reg…
rohilsurana Jul 31, 2026
4024862
feat(billing): list plans in any state with a StateAll filter sentinel
rohilsurana Jul 31, 2026
1d61f6d
feat(billing): add UpdatePlan to the plan service and handler interface
rohilsurana Jul 31, 2026
456c57d
feat(billing): serve plan writes and ListAllPlans on the admin handler
rohilsurana Jul 31, 2026
7a13966
feat(server): require super user for the AdminService plan RPCs
rohilsurana Jul 31, 2026
0a7b50f
test(e2e): create plans through the admin client
rohilsurana Jul 31, 2026
e82e91a
chore(proto): re-pin proton for UpdatePlanRequestBody and stricter pl…
rohilsurana Aug 3, 2026
7ba80b2
feat(billing): update plans via a dedicated body and return 404 for a…
rohilsurana Aug 3, 2026
db20d7d
fix(billing): stop coercing plan state on update, default new plans t…
rohilsurana Aug 3, 2026
b4cbe63
test(e2e): require state on create, cover UpdatePlan and ListAllPlans
rohilsurana Aug 3, 2026
2c72313
chore(proto): re-pin proton for inactive plan state enum
rohilsurana Aug 3, 2026
1782947
fix(billing): keep existing plan state when the seed file omits it
rohilsurana Aug 3, 2026
881a925
test(billing): use inactive instead of disabled for plan state
rohilsurana Aug 3, 2026
51cb281
fix(billing): read product behavior from the behavior column in plan …
rohilsurana Aug 4, 2026
6d675e5
feat(billing): add plan state constants and an IsInactive helper
rohilsurana Aug 4, 2026
d267f63
fix(billing): keep existing subscriptions resolving on inactive plans…
rohilsurana Aug 4, 2026
2572b82
feat(billing): block new checkouts onto inactive plans
rohilsurana Aug 4, 2026
971922a
fix(billing): map the inactive-plan rejection to a client error inste…
rohilsurana Aug 4, 2026
d6f6ca5
refactor(billing): honor StateAll in the plain List and use plan stat…
rohilsurana Aug 4, 2026
4c1417a
test(billing): cover the inactive-plan gate and the UpdatePlan error …
rohilsurana Aug 4, 2026
ad88362
test(e2e): assert UpdatePlan leaves name/interval/products intact and…
rohilsurana Aug 4, 2026
b5228df
chore(proto): re-pin to the merged proton commit for the plan admin APIs
rohilsurana Aug 4, 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
1 change: 1 addition & 0 deletions billing/checkout/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var (
ErrInvalidDetail = errors.New("invalid checkout detail")
ErrKycCompleted = errors.New("organization kyc completed")
ErrAlreadySubscribed = errors.New("already subscribed to the plan")
ErrPlanInactive = errors.New("plan is inactive and cannot be subscribed to")
)

type Checkout struct {
Expand Down
5 changes: 5 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 is inactive and cannot be subscribed to: %w", plan.Name, ErrPlanInactive)
}

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

Expand Down
14 changes: 14 additions & 0 deletions billing/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ var (
ErrInvalidDetail = errors.New("invalid plan detail")
)

const (
StateActive = "active"
StateInactive = "inactive"
// StateAll is a Filter.State sentinel that matches plans in any state. An
// empty Filter.State defaults to active, so this is the way to list every plan.
StateAll = "all"
)

// 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)
}
})
}
}
29 changes: 27 additions & 2 deletions billing/plan/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ 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
if _, err := s.planRepository.UpdateByName(ctx, existing); err != nil {
return Plan{}, err
}
return s.GetByID(ctx, existing.ID)
}

func (s Service) GetByID(ctx context.Context, id string) (Plan, error) {
var fetchedPlan Plan
var err error
Expand Down Expand Up @@ -280,7 +300,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 +314,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
11 changes: 11 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 is inactive and cannot be subscribed to: %w", planObj.Name, ErrPlanInactive)
}

// check if schedule exists
stripeSubscription, stripeSchedule, err := s.createOrGetSchedule(ctx, sub)
if err != nil {
Expand Down Expand Up @@ -1004,6 +1009,9 @@ func (s *Service) findPlanByStripeSubscription(ctx context.Context, stripeSubscr
plans, err := s.planService.List(ctx, plan.Filter{
IDs: productPlanIDs,
Interval: interval,
// resolve an existing subscription's plan regardless of state; an
// inactive (retired) plan must still resolve for its current subscribers
State: plan.StateAll,
})
if err != nil {
return plan.Plan{}, err
Expand Down Expand Up @@ -1040,6 +1048,9 @@ func (s *Service) findPlanByStripePhase(ctx context.Context, stripePhase *stripe
plans, err := s.planService.List(ctx, plan.Filter{
IDs: productPlanIDs,
Interval: interval,
// resolve an existing subscription's plan regardless of state; an
// inactive (retired) plan must still resolve for its current subscribers
State: plan.StateAll,
})
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: subscription.ErrPlanInactive,
},
}

for _, tt := range tests {
Expand Down
1 change: 1 addition & 0 deletions billing/subscription/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var (
ErrInvalidID = fmt.Errorf("invalid subscription id")
ErrInvalidDetail = fmt.Errorf("invalid subscription detail")
ErrAlreadyOnSamePlan = fmt.Errorf("already on the same plan")
ErrPlanInactive = fmt.Errorf("plan is inactive and cannot be subscribed to")
ErrNoPhaseActive = fmt.Errorf("no phase active")
ErrPhaseIsUpdating = fmt.Errorf("phase is in the middle of a change, please try again later")
ErrSubscriptionOnProviderNotFound = fmt.Errorf("failed to get subscription from billing provider")
Expand Down
2 changes: 2 additions & 0 deletions internal/api/v1beta1connect/billing_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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, checkout.ErrPlanInactive), errors.Is(err, subscription.ErrPlanInactive):
return connect.NewError(connect.CodeFailedPrecondition, ErrPlanInactive)
case errors.Is(err, product.ErrProductNotFound):
return connect.NewError(connect.CodeNotFound, product.ErrProductNotFound)
case errors.Is(err, product.ErrFeatureNotFound):
Expand Down
59 changes: 59 additions & 0 deletions internal/api/v1beta1connect/billing_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package v1beta1connect

import (
"context"
"errors"
"fmt"

"connectrpc.com/connect"
Expand Down Expand Up @@ -71,6 +72,7 @@ func (h *ConnectHandler) CreatePlan(ctx context.Context, request *connect.Reques
Products: products,
OnStartCredits: request.Msg.GetBody().GetOnStartCredits(),
TrialDays: request.Msg.GetBody().GetTrialDays(),
State: request.Msg.GetBody().GetState(),
Metadata: metaDataMap,
}

Expand All @@ -95,6 +97,39 @@ func (h *ConnectHandler) CreatePlan(ctx context.Context, request *connect.Reques
return connect.NewResponse(&frontierv1beta1.CreatePlanResponse{Plan: planPB}), nil
}

func (h *ConnectHandler) UpdatePlan(ctx context.Context, request *connect.Request[frontierv1beta1.UpdatePlanRequest]) (*connect.Response[frontierv1beta1.UpdatePlanResponse], error) {
body := request.Msg.GetBody()
// UpdatePlan changes a plan's own fields (title, description, credits, trial
// days, state, metadata). A plan's products are managed through CreatePlan's
// upsert, not here, so they are not read from the request.
updatedPlan, err := h.planService.UpdatePlan(ctx, plan.Plan{
ID: request.Msg.GetId(),
Title: body.GetTitle(),
Description: body.GetDescription(),
OnStartCredits: body.GetOnStartCredits(),
TrialDays: body.GetTrialDays(),
State: body.GetState(),
Metadata: metadata.Build(body.GetMetadata().AsMap()),
})
if err != nil {
switch {
case errors.Is(err, plan.ErrNotFound):
return nil, connect.NewError(connect.CodeNotFound, ErrNotFound)
case errors.Is(err, plan.ErrInvalidName), errors.Is(err, plan.ErrInvalidUUID):
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
default:
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdatePlan.UpdatePlan: plan_id=%s: %w", request.Msg.GetId(), err))
}
}

planPB, err := transformPlanToPB(updatedPlan)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdatePlan: plan_id=%s: %w", updatedPlan.ID, err))
}

return connect.NewResponse(&frontierv1beta1.UpdatePlanResponse{Plan: planPB}), nil
}

func (h *ConnectHandler) ListPlans(ctx context.Context, request *connect.Request[frontierv1beta1.ListPlansRequest]) (*connect.Response[frontierv1beta1.ListPlansResponse], error) {
var plans []*frontierv1beta1.Plan
planList, err := h.planService.List(ctx, plan.Filter{})
Expand All @@ -112,6 +147,29 @@ func (h *ConnectHandler) ListPlans(ctx context.Context, request *connect.Request
return connect.NewResponse(&frontierv1beta1.ListPlansResponse{Plans: plans}), nil
}

func (h *ConnectHandler) ListAllPlans(ctx context.Context, request *connect.Request[frontierv1beta1.ListAllPlansRequest]) (*connect.Response[frontierv1beta1.ListAllPlansResponse], error) {
// ListPlans lists active plans only. ListAllPlans lists every plan: an empty
// state means all states, and a set state filters to that state.
state := request.Msg.GetState()
if state == "" {
state = plan.StateAll
}
var plans []*frontierv1beta1.Plan
planList, err := h.planService.List(ctx, plan.Filter{State: state})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListAllPlans.List: %w", err))
}
for _, v := range planList {
planPB, err := transformPlanToPB(v)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListAllPlans: plan_id=%s: %w", v.ID, err))
}
plans = append(plans, planPB)
}

return connect.NewResponse(&frontierv1beta1.ListAllPlansResponse{Plans: plans}), nil
}

func (h *ConnectHandler) GetPlan(ctx context.Context, request *connect.Request[frontierv1beta1.GetPlanRequest]) (*connect.Response[frontierv1beta1.GetPlanResponse], error) {
planOb, err := h.planService.GetByID(ctx, request.Msg.GetId())
if err != nil {
Expand Down Expand Up @@ -153,6 +211,7 @@ func transformPlanToPB(p plan.Plan) (*frontierv1beta1.Plan, error) {
OnStartCredits: p.OnStartCredits,
Products: products,
TrialDays: p.TrialDays,
State: p.State,
Metadata: metaData,
CreatedAt: timestamppb.New(p.CreatedAt),
UpdatedAt: timestamppb.New(p.UpdatedAt),
Expand Down
Loading
Loading