From 42e8c87e548bd73d8ad698232151b2ebbbb1ff6f Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 4 Aug 2026 09:12:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(subscriptions):=20=C3=A0-la-carte=20add-on?= =?UTF-8?q?s=20=E2=80=94=20attach/detach=20fixed=20charges=20per=20subscri?= =?UTF-8?q?ption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lago's stock API can only *override* fixed charges that already exist on the shared plan (subscriptions/fixed_charges is index/show/update). There is no way to add an add-on to a single subscription, which is what self-serve à-la-carte billing needs. This adds that, on the open-core license (Option B): - Subscriptions::AddFixedChargeService — ensures the subscription has its own overridden plan (reusing PlanOverrideConcern), then creates the fixed charge on that override so the base plan and every other subscriber is untouched. Idempotent on the add-on code. - Subscriptions::RemoveFixedChargeService — discards an à-la-carte charge from the subscription's own override plan; refuses to touch charges on a shared plan. - subscriptions/fixed_charges#create + #destroy wired to those services, route extended to create/destroy. - Plans::OverrideService: allow the empty-params plan clone (what à-la-carte needs) on the open-core license; real overrides (custom pricing, commitments, trials) stay premium-only. Service specs assert the add/remove behavior works without a premium key and that the shared base plan is never mutated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../subscriptions/fixed_charges_controller.rb | 58 +++++++++++++++++ app/services/plans/override_service.rb | 6 +- .../subscriptions/add_fixed_charge_service.rb | 65 +++++++++++++++++++ .../remove_fixed_charge_service.rb | 45 +++++++++++++ config/routes.rb | 2 +- .../add_fixed_charge_service_spec.rb | 65 +++++++++++++++++++ .../remove_fixed_charge_service_spec.rb | 55 ++++++++++++++++ 7 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 app/services/subscriptions/add_fixed_charge_service.rb create mode 100644 app/services/subscriptions/remove_fixed_charge_service.rb create mode 100644 spec/services/subscriptions/add_fixed_charge_service_spec.rb create mode 100644 spec/services/subscriptions/remove_fixed_charge_service_spec.rb 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