diff --git a/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb b/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb new file mode 100644 index 000000000000..794a8a024ff8 --- /dev/null +++ b/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + module Subscriptions + # Switch an existing subscription to a different plan. + # Used by the "Change Plan" flow in the customer portal. + # Calls into Subscriptions::PlanUpgradeService which handles proration, + # invoice generation, and Stripe billing automatically. + class ChangePlan < BaseMutation + include AuthenticableCustomerPortalUser + + graphql_name "ChangeCustomerPortalSubscriptionPlan" + description "Switch a customer-portal subscription to a different plan" + + argument :subscription_id, ID, required: true, + description: "The Lago subscription to switch (must belong to the authenticated portal user)" + argument :plan_code, String, required: true, + description: "Target plan code within the same organization" + + type Types::Subscriptions::Object + + def resolve(subscription_id:, plan_code:) + customer = context[:customer_portal_user] + current_subscription = customer.subscriptions.find(subscription_id) + plan = customer.organization.plans.find_by!(code: plan_code, parent_id: nil) + + result = ::Subscriptions::PlanUpgradeService.call( + current_subscription: current_subscription, + plan: plan, + params: {name: current_subscription.name} + ) + + result.success? ? result.subscription : result_error(result) + rescue ActiveRecord::RecordNotFound + execution_error(message: "subscription_not_found") + end + end + end + end +end diff --git a/app/graphql/mutations/customer_portal/subscriptions/create.rb b/app/graphql/mutations/customer_portal/subscriptions/create.rb new file mode 100644 index 000000000000..948ab375a7c8 --- /dev/null +++ b/app/graphql/mutations/customer_portal/subscriptions/create.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + module Subscriptions + # Add a NEW subscription (e.g. customer adds Growth OS to an account + # that previously only had AI Stack). External_id follows the parent + # app's bridge convention: `${customer.external_id}-${productKey}` + # where productKey is derived from the plan code's prefix. + class Create < BaseMutation + include AuthenticableCustomerPortalUser + + graphql_name "CreateCustomerPortalSubscription" + description "Add a new product subscription for the customer-portal user" + + argument :plan_code, String, required: true, + description: "Plan code to subscribe to (must be visible to this customer's organization)" + + type Types::Subscriptions::Object + + def resolve(plan_code:) + customer = context[:customer_portal_user] + plan = customer.organization.plans.find_by!(code: plan_code, parent_id: nil) + product_key = plan_code.split("-").first + external_id = "#{customer.external_id}-#{product_key}" + + result = ::Subscriptions::CreateService.call( + customer: customer, + plan: plan, + params: { + name: nil, + external_id: external_id, + external_customer_id: customer.external_id, + billing_time: "calendar" + } + ) + + result.success? ? result.subscription : result_error(result) + rescue ActiveRecord::RecordNotFound + execution_error(message: "plan_not_found") + end + end + end + end +end diff --git a/app/graphql/mutations/customer_portal/subscriptions/terminate.rb b/app/graphql/mutations/customer_portal/subscriptions/terminate.rb new file mode 100644 index 000000000000..628de4b8bac6 --- /dev/null +++ b/app/graphql/mutations/customer_portal/subscriptions/terminate.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + module Subscriptions + # Cancel a single subscription within a multi-product account. + # The customer's other product subscriptions remain active. + class Terminate < BaseMutation + include AuthenticableCustomerPortalUser + + graphql_name "TerminateCustomerPortalSubscription" + description "Cancel a single subscription belonging to the customer-portal user" + + argument :subscription_id, ID, required: true + + type Types::Subscriptions::Object + + def resolve(subscription_id:) + customer = context[:customer_portal_user] + subscription = customer.subscriptions.find(subscription_id) + + result = ::Subscriptions::TerminateService.call(subscription: subscription) + result.success? ? result.subscription : result_error(result) + rescue ActiveRecord::RecordNotFound + execution_error(message: "subscription_not_found") + end + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/available_plans_resolver.rb b/app/graphql/resolvers/customer_portal/available_plans_resolver.rb new file mode 100644 index 000000000000..1663e6743a24 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/available_plans_resolver.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + # Lists plans visible to the current customer-portal user, optionally + # filtered to a single product via the `product_key` argument. + # + # Product key is extracted from the plan's `code` prefix: `aistack-*` → + # "aistack", `growth-*` → "growth", etc. This matches the prefix + # convention used by the parent app's bridge layer. + # + # When `exclude_current: true`, plans the customer is already subscribed + # to are removed from the result (used by the "Add a Product" cross-sell). + class AvailablePlansResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Lists plans available to the customer portal user, filtered by product" + + argument :product_key, String, required: false, + description: "Filter to plans whose code starts with `${product_key}-`" + argument :exclude_current, Boolean, required: false, default_value: false, + description: "Omit plans the customer already has an active subscription to" + + type [Types::Plans::Object], null: false + + def resolve(product_key: nil, exclude_current: false) + customer = context[:customer_portal_user] + organization = customer.organization + + plans = organization.plans.where(parent_id: nil).order(:amount_cents) + plans = plans.where("code LIKE ?", "#{product_key}-%") if product_key.present? + + if exclude_current + active_plan_codes = customer.subscriptions.active.joins(:plan).pluck("plans.code") + plans = plans.where.not(code: active_plan_codes) if active_plan_codes.any? + end + + plans + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 9587abfdae3d..e836ce54eec1 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -43,9 +43,12 @@ class MutationType < Types::BaseObject field :update_customer, mutation: Mutations::Customers::Update field :update_customer_invoice_grace_period, mutation: Mutations::Customers::UpdateInvoiceGracePeriod + field :change_customer_portal_subscription_plan, mutation: Mutations::CustomerPortal::Subscriptions::ChangePlan + field :create_customer_portal_subscription, mutation: Mutations::CustomerPortal::Subscriptions::Create field :create_customer_portal_wallet_transaction, mutation: Mutations::CustomerPortal::WalletTransactions::Create field :download_customer_portal_invoice, mutation: Mutations::CustomerPortal::DownloadInvoice field :generate_customer_portal_url, mutation: Mutations::CustomerPortal::GenerateUrl + field :terminate_customer_portal_subscription, mutation: Mutations::CustomerPortal::Subscriptions::Terminate field :update_customer_portal_customer, mutation: Mutations::CustomerPortal::UpdateCustomer field :create_credit_notes_data_export, mutation: Mutations::DataExports::CreditNotes::Create diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 337ae1ade9fb..e0d5ad009fd0 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -35,6 +35,7 @@ class QueryType < Types::BaseObject field :current_version, resolver: Resolvers::VersionResolver field :customer, resolver: Resolvers::CustomerResolver field :customer_invoices, resolver: Resolvers::Customers::InvoicesResolver + field :customer_portal_available_plans, resolver: Resolvers::CustomerPortal::AvailablePlansResolver field :customer_portal_customer_projected_usage, resolver: Resolvers::CustomerPortal::Customers::ProjectedUsageResolver field :customer_portal_customer_usage, resolver: Resolvers::CustomerPortal::Customers::UsageResolver field :customer_portal_invoice_collections, resolver: Resolvers::CustomerPortal::Analytics::InvoiceCollectionsResolver