Skip to content

Commit 41c1197

Browse files
committed
feat(products): attach rate cards directly to subscriptions
Sales-led subscriptions need rate cards attached and amended on the subscription itself, without going through a plan. Snapshotted entries also need sub-level phase amendments, since they are the subscription's own copy of the plan's pricing. Add SubscriptionProductItems::CreateService to attach a rate card directly to a subscription (with a default indefinite phase), mirroring the plan-side flow. Billing then follows the same path as snapshotted items. Generalize RatePhases::ReplaceService to accept either a plan or a subscription entry as parent: plan-level phases stay locked once the plan has subscriptions, while a subscription's own phases remain editable (amendment flow). Expose the REST endpoints under /api/v2/subscription_product_items (index, show, create, nested rate_phases index/replace), with query object, serializer, and the subscription_product_item API permission.
1 parent ff9fbdd commit 41c1197

13 files changed

Lines changed: 591 additions & 10 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V2
5+
module SubscriptionProductItems
6+
class RatePhasesController < Api::BaseController
7+
def index
8+
return not_found_error(resource: "subscription_product_item") unless subscription_product_item
9+
10+
render_rate_phases(subscription_product_item.rate_phases.order(:position))
11+
end
12+
13+
def replace
14+
return not_found_error(resource: "subscription_product_item") unless subscription_product_item
15+
16+
result = ::RatePhases::ReplaceService.call(
17+
subscription_product_item:,
18+
phases_params: replace_params
19+
)
20+
21+
if result.success?
22+
render_rate_phases(result.rate_phases)
23+
else
24+
render_error_response(result)
25+
end
26+
end
27+
28+
private
29+
30+
def subscription_product_item
31+
@subscription_product_item ||= SubscriptionProductItem
32+
.where(organization: current_organization)
33+
.find_by(id: params[:subscription_product_item_id])
34+
end
35+
36+
def replace_params
37+
params
38+
.permit(rate_phases: [
39+
:position,
40+
:name,
41+
:billing_interval_cycle_count,
42+
{rate_override: [
43+
:rate_model,
44+
:min_amount_cents,
45+
:billing_interval_count,
46+
:billing_interval_unit,
47+
:pricing_unit_conversion_rate,
48+
{rate_properties: {}}
49+
]}
50+
])
51+
.to_h
52+
.deep_symbolize_keys
53+
.fetch(:rate_phases, [])
54+
end
55+
56+
def render_rate_phases(rate_phases)
57+
render(
58+
json: ::CollectionSerializer.new(
59+
rate_phases,
60+
::V1::RatePhaseSerializer,
61+
collection_name: "rate_phases"
62+
)
63+
)
64+
end
65+
66+
def resource_name
67+
"subscription_product_item"
68+
end
69+
end
70+
end
71+
end
72+
end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V2
5+
class SubscriptionProductItemsController < Api::BaseController
6+
def create
7+
subscription = current_organization.subscriptions.find_by(id: create_params[:subscription_id])
8+
result = ::SubscriptionProductItems::CreateService.call(
9+
subscription:,
10+
params: create_params.except(:subscription_id).to_h.deep_symbolize_keys
11+
)
12+
13+
if result.success?
14+
render_subscription_product_item(result.subscription_product_item)
15+
else
16+
render_error_response(result)
17+
end
18+
end
19+
20+
def show
21+
subscription_product_item = subscription_product_items_scope.find_by(id: params[:id])
22+
23+
return not_found_error(resource: "subscription_product_item") unless subscription_product_item
24+
25+
render_subscription_product_item(subscription_product_item)
26+
end
27+
28+
def index
29+
result = ::SubscriptionProductItemsQuery.call(
30+
organization: current_organization,
31+
pagination: {
32+
page: params[:page],
33+
limit: params[:per_page] || PER_PAGE
34+
},
35+
filters: {subscription_id: params[:subscription_id]}
36+
)
37+
38+
if result.success?
39+
render(
40+
json: ::CollectionSerializer.new(
41+
result.subscription_product_items,
42+
::V1::SubscriptionProductItemSerializer,
43+
collection_name: "subscription_product_items",
44+
meta: pagination_metadata(result.subscription_product_items)
45+
)
46+
)
47+
else
48+
render_error_response(result)
49+
end
50+
end
51+
52+
private
53+
54+
def subscription_product_items_scope
55+
SubscriptionProductItem.where(organization: current_organization)
56+
end
57+
58+
def create_params
59+
params.require(:subscription_product_item).permit(:subscription_id, :rate_card_code, :units, :started_at, :billing_anchor_date)
60+
end
61+
62+
def render_subscription_product_item(subscription_product_item)
63+
render(json: ::V1::SubscriptionProductItemSerializer.new(subscription_product_item, root_name: "subscription_product_item"))
64+
end
65+
66+
def resource_name
67+
"subscription_product_item"
68+
end
69+
end
70+
end
71+
end

app/models/api_key.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class ApiKey < ApplicationRecord
88
customer event fee invoice organization order order_form payment payment_receipt payment_request payment_method plan subscription lifetime_usage
99
tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section
1010
billing_entity alert feature security_log quote product product_item rate_card plan_product_item
11+
subscription_product_item
1112
].freeze
1213

1314
MODES = %w[read write].freeze
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
class SubscriptionProductItemsQuery < BaseQuery
4+
Result = BaseResult[:subscription_product_items]
5+
Filters = BaseFilters[:subscription_id]
6+
7+
def call
8+
subscription_product_items = base_scope
9+
subscription_product_items = with_subscription(subscription_product_items) if filters.subscription_id.present?
10+
subscription_product_items = paginate(subscription_product_items)
11+
subscription_product_items = apply_consistent_ordering(subscription_product_items)
12+
13+
result.subscription_product_items = subscription_product_items
14+
result
15+
end
16+
17+
private
18+
19+
def base_scope
20+
SubscriptionProductItem.where(organization:)
21+
end
22+
23+
def with_subscription(scope)
24+
scope.where(subscription_id: filters.subscription_id)
25+
end
26+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module V1
4+
class SubscriptionProductItemSerializer < ModelSerializer
5+
def serialize
6+
{
7+
lago_id: model.id,
8+
lago_subscription_id: model.subscription_id,
9+
lago_rate_card_id: model.rate_card_id,
10+
rate_card_code: model.rate_card.code,
11+
units: model.units,
12+
started_at: model.started_at.iso8601,
13+
ended_at: model.ended_at&.iso8601,
14+
billing_anchor_date: model.billing_anchor_date.iso8601,
15+
next_billing_at: model.next_billing_at.iso8601,
16+
rate_phases_count: model.rate_phases.count,
17+
created_at: model.created_at.iso8601
18+
}
19+
end
20+
end
21+
end

app/services/rate_phases/replace_service.rb

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ module RatePhases
44
class ReplaceService < BaseService
55
Result = BaseResult[:rate_phases]
66

7-
def initialize(plan_product_item:, phases_params:)
7+
def initialize(plan_product_item: nil, subscription_product_item: nil, phases_params: [])
88
@plan_product_item = plan_product_item
9+
@subscription_product_item = subscription_product_item
910
@phases_params = Array.wrap(phases_params).map { |phase| phase.to_h.with_indifferent_access }
1011
super
1112
end
1213

1314
def call
14-
return result.not_found_failure!(resource: "plan_product_item") unless plan_product_item
15+
return result.not_found_failure!(resource: "rate_phaseable") unless parent
1516
return result.single_validation_failure!(field: :rate_phases, error_code: "plan_locked") if plan_locked?
1617

1718
sequence_failure = validate_sequence
@@ -21,7 +22,7 @@ def call
2122
discard_existing_phases
2223

2324
result.rate_phases = ordered_params.map do |phase|
24-
plan_product_item.rate_phases.create!(
25+
parent.rate_phases.create!(
2526
organization:,
2627
position: phase[:position],
2728
name: phase[:name],
@@ -40,29 +41,35 @@ def call
4041

4142
private
4243

43-
attr_reader :plan_product_item, :phases_params
44+
attr_reader :plan_product_item, :subscription_product_item, :phases_params
45+
46+
def parent
47+
plan_product_item || subscription_product_item
48+
end
4449

4550
def organization
46-
plan_product_item.organization
51+
parent.organization
4752
end
4853

4954
def discard_existing_phases
50-
existing_phases = plan_product_item.rate_phases.to_a
55+
existing_phases = parent.rate_phases.to_a
5156
RateOverride.where(id: existing_phases.filter_map(&:rate_override_id)).discard_all!
52-
plan_product_item.rate_phases.discard_all!
57+
parent.rate_phases.discard_all!
5358
end
5459

5560
def build_override(phase)
5661
return if phase[:rate_override].blank?
5762

5863
RateOverrides::CreateService.call(
59-
rate_card: plan_product_item.rate_card,
64+
rate_card: parent.rate_card,
6065
params: phase[:rate_override]
6166
).raise_if_error!.rate_override
6267
end
6368

69+
# Plan-level phases freeze once the plan has subscriptions; a subscription's
70+
# own phases stay editable — they are the sub's copy (amendment flow).
6471
def plan_locked?
65-
plan_product_item.plan.attached_to_subscriptions?
72+
plan_product_item.present? && plan_product_item.plan.attached_to_subscriptions?
6673
end
6774

6875
def ordered_params
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
module SubscriptionProductItems
4+
# Attaches a rate card directly to a subscription (sales-led flow). Unlike
5+
# plan snapshots, the entry is authored on the subscription itself; billing
6+
# then follows the same path as snapshotted items.
7+
class CreateService < BaseService
8+
Result = BaseResult[:subscription_product_item]
9+
10+
def initialize(subscription:, params:)
11+
@subscription = subscription
12+
@params = params.to_h.with_indifferent_access
13+
super
14+
end
15+
16+
def call
17+
return result.not_found_failure!(resource: "subscription") unless subscription
18+
19+
rate_card = organization.rate_cards.find_by(code: params[:rate_card_code])
20+
return result.not_found_failure!(resource: "rate_card") unless rate_card
21+
22+
started_at = params[:started_at].presence || Time.current
23+
24+
ActiveRecord::Base.transaction do
25+
subscription_product_item = subscription.subscription_product_items.create!(
26+
organization:,
27+
rate_card:,
28+
units: params[:units],
29+
started_at:,
30+
billing_anchor_date: params[:billing_anchor_date].presence || started_at.to_date,
31+
next_billing_at: started_at
32+
)
33+
34+
RatePhases::CreateService.call!(subscription_product_item:, params: {position: 1})
35+
36+
result.subscription_product_item = subscription_product_item
37+
end
38+
39+
result
40+
rescue ActiveRecord::RecordInvalid => e
41+
result.record_validation_failure!(record: e.record)
42+
end
43+
44+
private
45+
46+
attr_reader :subscription, :params
47+
48+
def organization
49+
subscription.organization
50+
end
51+
end
52+
end

config/routes.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
put "rate_phases", to: "rate_phases#replace"
4949
end
5050
end
51+
resources :subscription_product_items, only: %i[index show create] do
52+
scope module: :subscription_product_items do
53+
get "rate_phases", to: "rate_phases#index"
54+
put "rate_phases", to: "rate_phases#replace"
55+
end
56+
end
5157
end
5258
end
5359
resources :webhooks, only: [] do
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe SubscriptionProductItemsQuery, type: :query do
6+
subject(:result) { described_class.call(organization:, pagination:, filters:) }
7+
8+
let(:organization) { create(:organization) }
9+
let(:pagination) { nil }
10+
let(:filters) { {} }
11+
12+
let(:customer) { create(:customer, organization:) }
13+
let(:subscription) { create(:subscription, customer:, organization:) }
14+
let!(:subscription_product_item) { create(:subscription_product_item, organization:, subscription:) }
15+
let!(:other_subscription_product_item) { create(:subscription_product_item, organization:) }
16+
17+
it "returns all subscription product items of the organization" do
18+
expect(result).to be_success
19+
expect(result.subscription_product_items).to match_array([subscription_product_item, other_subscription_product_item])
20+
end
21+
22+
context "when filtering by subscription_id" do
23+
let(:filters) { {subscription_id: subscription.id} }
24+
25+
it "returns only the subscription's product items" do
26+
expect(result.subscription_product_items).to eq([subscription_product_item])
27+
end
28+
end
29+
end

0 commit comments

Comments
 (0)