diff --git a/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb b/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb index 762e53d2dc0b..e6a15063423f 100644 --- a/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb +++ b/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb @@ -54,6 +54,46 @@ def update end end + # Attach a new à-la-carte add-on (fixed charge) to this subscription only. + def create + result = ::Subscriptions::AddFixedChargeService.call( + subscription:, + params: create_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::FixedChargeSerializer.new( + result.fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + # Detach an à-la-carte add-on from this subscription. + def destroy + result = ::Subscriptions::RemoveFixedChargeService.call( + subscription:, + code: params[:code] + ) + + if result.success? + render( + json: ::V1::FixedChargeSerializer.new( + result.fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + private attr_reader :fixed_charge @@ -72,6 +112,24 @@ def input_params ) end + # Attaching a new add-on needs the full fixed-charge shape (which add-on, + # a code, the charge model, etc.) — the update path only tweaks units. + def create_params + params.require(:fixed_charge).permit( + :add_on_id, + :add_on_code, + :code, + :invoice_display_name, + :charge_model, + :pay_in_advance, + :prorated, + :units, + :apply_units_immediately, + properties: {}, + tax_codes: [] + ) + end + def find_fixed_charge @fixed_charge = subscription.plan.fixed_charges.find_by(code: params[:code]) not_found_error(resource: "fixed_charge") unless @fixed_charge diff --git a/app/services/plans/override_service.rb b/app/services/plans/override_service.rb index 335940565614..5a759717bc05 100644 --- a/app/services/plans/override_service.rb +++ b/app/services/plans/override_service.rb @@ -13,7 +13,11 @@ def initialize(plan:, params:, subscription: nil) end def call - return result.forbidden_failure! unless License.premium? + # À-la-carte add-ons attach a fixed charge to a single subscription, which + # requires cloning the plan into a per-subscription override (empty params — + # a pure clone, no custom pricing). Allow that on the open-core license. + # Real overrides (custom amounts, commitments, trials) stay premium-only. + return result.forbidden_failure! unless License.premium? || params.blank? ActiveRecord::Base.transaction do new_plan = plan.dup.tap do |p| diff --git a/app/services/subscriptions/add_fixed_charge_service.rb b/app/services/subscriptions/add_fixed_charge_service.rb new file mode 100644 index 000000000000..c5c1fa4d41b2 --- /dev/null +++ b/app/services/subscriptions/add_fixed_charge_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Subscriptions + class AddFixedChargeService < BaseService + include Concerns::PlanOverrideConcern + + Result = BaseResult[:fixed_charge] + + # Attach a brand-new fixed charge (an à-la-carte add-on) to a single + # subscription. Lago's stock API only lets you *override* fixed charges that + # already exist on the shared plan; there is no way to add one to a lone + # subscription. This service closes that gap: it ensures the subscription has + # its own overridden plan, then creates the fixed charge on that override so + # the base plan (and every other subscriber) is untouched. + # + # This is intentionally NOT premium-gated — self-serve à-la-carte add-ons are + # the whole point (see feat/subscription-alacarte-addons). + def initialize(subscription:, params:) + @subscription = subscription + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + + ActiveRecord::Base.transaction do + target_plan = ensure_plan_override + + # Idempotency: if this add-on code is already attached to the override, + # return it instead of failing on the unique (plan, code) constraint. + existing = target_plan.fixed_charges.find_by(code: params[:code]) + if existing + result.fixed_charge = existing + return result + end + + # CreateService emits fixed-charge events itself. Because ensure_plan_override + # has already repointed this subscription at the override plan, that plan's + # only subscriber is us — so CreateService's internal emit fires for exactly + # this subscription (respecting apply_units_immediately). No extra emit here, + # or events would be double-counted. + create_result = FixedCharges::CreateService.call( + plan: target_plan, + params: params, + cascade_updates: false + ) + create_result.raise_if_error! + + result.fixed_charge = create_result.fixed_charge + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :params + end +end diff --git a/app/services/subscriptions/remove_fixed_charge_service.rb b/app/services/subscriptions/remove_fixed_charge_service.rb new file mode 100644 index 000000000000..9af0faadc75d --- /dev/null +++ b/app/services/subscriptions/remove_fixed_charge_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Subscriptions + class RemoveFixedChargeService < BaseService + Result = BaseResult[:fixed_charge] + + # Detach an à-la-carte fixed charge from a single subscription. Only charges + # that live on the subscription's *own* overridden plan may be removed — you + # cannot delete a charge off the shared base plan through this path, since + # that would affect every other subscriber. Not premium-gated (see + # AddFixedChargeService). + def initialize(subscription:, code:) + @subscription = subscription + @code = code + + super + end + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + + plan = subscription.plan + + # Guard: refuse to touch charges on a shared (non-override) plan. + unless plan.parent_id + return result.forbidden_failure!(code: "not_a_subscription_override") + end + + fixed_charge = plan.fixed_charges.find_by(code: code) + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + + destroy_result = FixedCharges::DestroyService.call(fixed_charge:, cascade_updates: false) + destroy_result.raise_if_error! + + result.fixed_charge = destroy_result.fixed_charge + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :code + end +end diff --git a/config/routes.rb b/config/routes.rb index 631545794f99..cc0b0f946cda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,7 +103,7 @@ resources :privileges, only: %i[destroy], param: :code, code: /.*/, controller: "subscriptions/entitlements/privileges" end patch :entitlements, to: "subscriptions/entitlements#update" - resources :fixed_charges, only: %i[index show update], param: :code, code: /.*/, controller: "subscriptions/fixed_charges" + resources :fixed_charges, only: %i[index show create update destroy], param: :code, code: /.*/, controller: "subscriptions/fixed_charges" resources :charges, only: %i[index show update], param: :code, code: /.*/, controller: "subscriptions/charges" do resources :filters, only: %i[index show create update destroy], controller: "subscriptions/charges/filters" end diff --git a/spec/services/subscriptions/add_fixed_charge_service_spec.rb b/spec/services/subscriptions/add_fixed_charge_service_spec.rb new file mode 100644 index 000000000000..9ac7e726ad3b --- /dev/null +++ b/spec/services/subscriptions/add_fixed_charge_service_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::AddFixedChargeService do + subject(:service) { described_class.new(subscription:, params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:params) do + { + add_on_code: add_on.code, + code: "extra_seat", + charge_model: "standard", + units: "1" + } + end + + describe "#call" do + before { subscription } + + # The whole point of Option B: à-la-carte add-ons work on the open-core + # license, without a premium key. + context "without premium license" do + it "clones the plan into a per-subscription override" do + expect { service.call }.to change(Plan, :count).by(1) + + new_plan = subscription.reload.plan + expect(new_plan.parent_id).to eq(plan.id) + end + + it "creates the fixed charge on the override plan" do + result = service.call + + expect(result).to be_success + expect(result.fixed_charge.code).to eq("extra_seat") + expect(result.fixed_charge.plan.parent_id).to eq(plan.id) + expect(result.fixed_charge.add_on_id).to eq(add_on.id) + end + + it "leaves the shared base plan untouched" do + expect { service.call }.not_to change { plan.reload.fixed_charges.count } + end + + it "is idempotent on the add-on code" do + service.call + expect { described_class.new(subscription: subscription.reload, params:).call } + .not_to change(FixedCharge, :count) + end + end + + context "when the subscription is missing" do + subject(:service) { described_class.new(subscription: nil, params:) } + + it "returns a not-found failure" do + result = service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + end +end diff --git a/spec/services/subscriptions/remove_fixed_charge_service_spec.rb b/spec/services/subscriptions/remove_fixed_charge_service_spec.rb new file mode 100644 index 000000000000..e5f9e9e4245a --- /dev/null +++ b/spec/services/subscriptions/remove_fixed_charge_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::RemoveFixedChargeService do + subject(:service) { described_class.new(subscription:, code:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:base_plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:code) { "extra_seat" } + + describe "#call" do + before { subscription } + + context "when the charge lives on the subscription's own override plan" do + let(:plan) { create(:plan, organization:, parent_id: base_plan.id) } + let!(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:, code:) } + + it "discards the fixed charge" do + result = service.call + + expect(result).to be_success + expect(fixed_charge.reload.discarded?).to be(true) + end + end + + context "when the subscription is on a shared (non-override) plan" do + let(:plan) { base_plan } + let!(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:, code:) } + + it "refuses — you cannot strip a charge off a shared plan" do + result = service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("not_a_subscription_override") + expect(fixed_charge.reload.discarded?).to be(false) + end + end + + context "when no charge matches the code" do + let(:plan) { create(:plan, organization:, parent_id: base_plan.id) } + let(:code) { "does_not_exist" } + + it "returns a not-found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + end +end