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
58 changes: 58 additions & 0 deletions app/controllers/api/v1/subscriptions/fixed_charges_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app/services/plans/override_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
65 changes: 65 additions & 0 deletions app/services/subscriptions/add_fixed_charge_service.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions app/services/subscriptions/remove_fixed_charge_service.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions spec/services/subscriptions/add_fixed_charge_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions spec/services/subscriptions/remove_fixed_charge_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading