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
41 changes: 41 additions & 0 deletions app/graphql/mutations/customer_portal/subscriptions/change_plan.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions app/graphql/mutations/customer_portal/subscriptions/create.rb
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions app/graphql/mutations/customer_portal/subscriptions/terminate.rb
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions app/graphql/resolvers/customer_portal/available_plans_resolver.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/graphql/types/query_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading