From 7845e136a698a5a92eb8ee5c611eec8c6e0f5aed Mon Sep 17 00:00:00 2001 From: rrader26 Date: Wed, 1 Jul 2026 14:05:11 -0400 Subject: [PATCH] feat(plans): first-class self_serve flag to hide plans from the customer portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `self_serve` boolean to plans (default true, non-breaking). When false, the plan is excluded from the customer-portal self-serve list (AvailablePlansResolver) but stays fully assignable by operators — for contract-only tiers like enterprise / white-label. Replaces the ad-hoc `hidden_in_portal` metadata approach with a proper, toggleable field. - migration: add_column :plans, :self_serve, :boolean, default: true, null: false - AvailablePlansResolver: base scope now `.where(self_serve: true)` - Plans::CreateService / UpdateService: persist self_serve (editable even when attached to subscriptions — it's not a billing attribute) - REST: permit :self_serve param + expose in V1::PlanSerializer - GraphQL: Plans::Object field + Create/Update input arguments Post-merge / CI must regenerate the checked-in artifacts (env-dependent, not done here): `structure.sql` via `rails db:migrate`, and `schema.graphql` via the GraphQL schema dump task. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/api/v1/plans_controller.rb | 1 + .../customer_portal/available_plans_resolver.rb | 4 ++++ app/graphql/types/plans/create_input.rb | 1 + app/graphql/types/plans/object.rb | 1 + app/graphql/types/plans/update_input.rb | 1 + app/serializers/v1/plan_serializer.rb | 1 + app/services/plans/create_service.rb | 3 ++- app/services/plans/update_service.rb | 3 +++ db/migrate/20260701000000_add_self_serve_to_plans.rb | 12 ++++++++++++ 9 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260701000000_add_self_serve_to_plans.rb diff --git a/app/controllers/api/v1/plans_controller.rb b/app/controllers/api/v1/plans_controller.rb index 3a90cc80f380..e460373f6440 100644 --- a/app/controllers/api/v1/plans_controller.rb +++ b/app/controllers/api/v1/plans_controller.rb @@ -112,6 +112,7 @@ def input_params :amount_currency, :trial_period, :pay_in_advance, + :self_serve, :bill_charges_monthly, :bill_fixed_charges_monthly, :cascade_updates, diff --git a/app/graphql/resolvers/customer_portal/available_plans_resolver.rb b/app/graphql/resolvers/customer_portal/available_plans_resolver.rb index ee7b74941ef8..9c71dac35c1f 100644 --- a/app/graphql/resolvers/customer_portal/available_plans_resolver.rb +++ b/app/graphql/resolvers/customer_portal/available_plans_resolver.rb @@ -27,7 +27,11 @@ def resolve(product_key: nil, exclude_current: false) customer = context[:customer_portal_user] organization = customer.organization + # `self_serve: false` plans (contract-only tiers like enterprise / + # white-label) never appear in the portal, but stay assignable by + # operators (this filter only runs here). plans = organization.plans.where(parent_id: nil) + .where(self_serve: true) .includes(:metadata) .order(:amount_cents) plans = plans.where("code LIKE ?", "#{product_key}-%") if product_key.present? diff --git a/app/graphql/types/plans/create_input.rb b/app/graphql/types/plans/create_input.rb index 75ca53f18d38..e1ac684b73a9 100644 --- a/app/graphql/types/plans/create_input.rb +++ b/app/graphql/types/plans/create_input.rb @@ -16,6 +16,7 @@ class CreateInput < Types::BaseInputObject argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS argument :name, String, required: true argument :pay_in_advance, Boolean, required: true + argument :self_serve, Boolean, required: false argument :tax_codes, [String], required: false argument :trial_period, Float, required: false diff --git a/app/graphql/types/plans/object.rb b/app/graphql/types/plans/object.rb index 33ffc0650555..7a94cf70b31d 100644 --- a/app/graphql/types/plans/object.rb +++ b/app/graphql/types/plans/object.rb @@ -20,6 +20,7 @@ class Object < Types::BaseObject field :name, String, null: false field :parent, Types::Plans::Object, null: true field :pay_in_advance, Boolean, null: false + field :self_serve, Boolean, null: false, description: "Whether the plan is offered in the self-serve customer portal" field :trial_period, Float field :applicable_usage_thresholds, [Types::UsageThresholds::Object] diff --git a/app/graphql/types/plans/update_input.rb b/app/graphql/types/plans/update_input.rb index 5ea51fe1041f..751c584cc31f 100644 --- a/app/graphql/types/plans/update_input.rb +++ b/app/graphql/types/plans/update_input.rb @@ -19,6 +19,7 @@ class UpdateInput < Types::BaseInputObject argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS argument :name, String, required: true argument :pay_in_advance, Boolean, required: true + argument :self_serve, Boolean, required: false argument :tax_codes, [String], required: false argument :trial_period, Float, required: false diff --git a/app/serializers/v1/plan_serializer.rb b/app/serializers/v1/plan_serializer.rb index 37b4f3e96cac..d69858200cdb 100644 --- a/app/serializers/v1/plan_serializer.rb +++ b/app/serializers/v1/plan_serializer.rb @@ -15,6 +15,7 @@ def serialize amount_currency: model.amount_currency, trial_period: model.trial_period, pay_in_advance: model.pay_in_advance, + self_serve: model.self_serve, bill_charges_monthly: model.bill_charges_monthly, bill_fixed_charges_monthly: model.bill_fixed_charges_monthly, customers_count: 0, diff --git a/app/services/plans/create_service.rb b/app/services/plans/create_service.rb index e02ead4c49eb..48e27422957c 100644 --- a/app/services/plans/create_service.rb +++ b/app/services/plans/create_service.rb @@ -27,7 +27,8 @@ def call amount_currency: args[:amount_currency], trial_period: args[:trial_period], bill_charges_monthly: bill_charges_monthly(args), - bill_fixed_charges_monthly: bill_fixed_charges_monthly(args) + bill_fixed_charges_monthly: bill_fixed_charges_monthly(args), + self_serve: args[:self_serve].nil? ? true : args[:self_serve] ) chargeables_validation_result = Plans::ChargeablesValidationService.call( diff --git a/app/services/plans/update_service.rb b/app/services/plans/update_service.rb index 8986b7223c53..a3351ce9d734 100644 --- a/app/services/plans/update_service.rb +++ b/app/services/plans/update_service.rb @@ -27,6 +27,9 @@ def call plan.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) plan.description = params[:description] if params.key?(:description) plan.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + # Portal visibility — editable even while attached to subscriptions + # (it doesn't affect billing, only the self-serve plan list). + plan.self_serve = params[:self_serve] if params.key?(:self_serve) # NOTE: If plan is attached to subscriptions the editable attributes are: # name, invoice_display_name, description, amount_cents diff --git a/db/migrate/20260701000000_add_self_serve_to_plans.rb b/db/migrate/20260701000000_add_self_serve_to_plans.rb new file mode 100644 index 000000000000..e53599c83917 --- /dev/null +++ b/db/migrate/20260701000000_add_self_serve_to_plans.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Adds a first-class `self_serve` flag to plans. When false, the plan is +# hidden from the customer-portal self-serve list (AvailablePlansResolver) +# but stays fully assignable by operators — used for contract-only tiers +# such as enterprise / white-label. Defaults true so every existing plan +# stays self-serve (non-breaking). +class AddSelfServeToPlans < ActiveRecord::Migration[8.0] + def change + add_column :plans, :self_serve, :boolean, default: true, null: false + end +end