Skip to content

Commit 4750454

Browse files
rsempeclaude
andcommitted
feat(api): serve subscriptions on the v2 API
## Context The v1 subscription payload is built around the plan interval: amounts, trial and current billing period. A product-catalog subscription has none of that — it prices through its rate card entries, each carrying its own billing cycle — so v2 consumers were reading legacy fields that can only be null. ## Description Add V2::SubscriptionSerializer (identity, status, lifecycle dates, plan_code, and the subscription's rate card entries — embedded on show, counted on index) and an Api::V2::SubscriptionsController serving index and show. The catalog v2 routes now draw before the v1 fallback so the first match wins for the paths they define; everything else on /api/v2/subscriptions (create, terminate) still falls through to v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5530f9d commit 4750454

4 files changed

Lines changed: 166 additions & 4 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V2
5+
class SubscriptionsController < Api::BaseController
6+
def index
7+
filters = params.permit(:plan_code, :external_customer_id, :external_id, status: [])
8+
filters[:status] = ["active"] if filters[:status].blank?
9+
10+
result = ::SubscriptionsQuery.call(
11+
organization: current_organization,
12+
pagination: {
13+
page: params[:page],
14+
limit: params[:per_page] || PER_PAGE
15+
},
16+
filters:
17+
)
18+
19+
if result.success?
20+
subscriptions = result.subscriptions.includes(:plan, customer: :billing_entity)
21+
22+
render(
23+
json: ::CollectionSerializer.new(
24+
subscriptions,
25+
::V2::SubscriptionSerializer,
26+
collection_name: "subscriptions",
27+
meta: pagination_metadata(subscriptions)
28+
)
29+
)
30+
else
31+
render_error_response(result)
32+
end
33+
end
34+
35+
def show
36+
subscription = current_organization.subscriptions
37+
.order("terminated_at DESC NULLS FIRST, started_at DESC")
38+
.find_by(
39+
external_id: params[:external_id],
40+
status: params[:status] || :active
41+
)
42+
return not_found_error(resource: "subscription") unless subscription
43+
44+
render(
45+
json: ::V2::SubscriptionSerializer.new(
46+
subscription,
47+
root_name: "subscription",
48+
includes: %i[subscription_product_items]
49+
)
50+
)
51+
end
52+
53+
private
54+
55+
def resource_name
56+
"subscription"
57+
end
58+
end
59+
end
60+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
module V2
4+
# The v2 shape drops the plan-interval fields (amounts, billing periods,
5+
# trial): a product-catalog subscription prices through its rate card
6+
# entries, each carrying its own billing cycle.
7+
class SubscriptionSerializer < ModelSerializer
8+
def serialize
9+
payload = {
10+
lago_id: model.id,
11+
external_id: model.external_id,
12+
lago_customer_id: model.customer_id,
13+
external_customer_id: model.customer.external_id,
14+
name: model.name,
15+
plan_code: model.plan.code,
16+
status: model.status,
17+
billing_time: model.billing_time,
18+
subscription_at: model.subscription_at&.iso8601,
19+
started_at: model.started_at&.iso8601,
20+
ending_at: model.ending_at&.iso8601,
21+
terminated_at: model.terminated_at&.iso8601,
22+
canceled_at: model.canceled_at&.iso8601,
23+
created_at: model.created_at.iso8601,
24+
subscription_product_items_count: model.subscription_product_items.count
25+
}
26+
27+
payload[:subscription_product_items] = subscription_product_items if include?(:subscription_product_items)
28+
29+
payload
30+
end
31+
32+
private
33+
34+
def subscription_product_items
35+
::CollectionSerializer.new(
36+
model.subscription_product_items,
37+
::V1::SubscriptionProductItemSerializer,
38+
collection_name: "subscription_product_items"
39+
).serialize[:subscription_product_items]
40+
end
41+
end
42+
end

config/routes.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,8 @@
3030
draw(:shared_api)
3131
end
3232

33-
namespace :v2, module: :v1 do
34-
draw(:shared_api)
35-
end
36-
33+
# Catalog-specific v2 routes are drawn before the v1 fallback: for the
34+
# paths defined here (e.g. GET /api/v2/subscriptions) the first match wins.
3735
namespace :v2 do
3836
resources :product_items, only: %i[index show create update destroy] do
3937
resources :filters, only: %i[index show create update destroy], controller: "product_items/filters"
@@ -54,6 +52,11 @@
5452
put "rate_phases", to: "rate_phases#replace"
5553
end
5654
end
55+
resources :subscriptions, only: %i[index show], param: :external_id
56+
end
57+
58+
namespace :v2, module: :v1 do
59+
draw(:shared_api)
5760
end
5861
end
5962
resources :webhooks, only: [] do
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Api::V2::SubscriptionsController do
6+
let(:organization) { create(:organization) }
7+
let(:customer) { create(:customer, organization:) }
8+
let(:plan) { create(:plan, organization:, pricing_type: "product_catalog") }
9+
let(:subscription) { create(:subscription, customer:, plan:, organization:) }
10+
11+
describe "GET /api/v2/subscriptions" do
12+
subject { get_with_token(organization, "/api/v2/subscriptions") }
13+
14+
before { create(:subscription_product_item, organization:, subscription:) }
15+
16+
include_examples "requires API permission", "subscription", "read"
17+
18+
it "returns subscriptions in the v2 shape" do
19+
subject
20+
21+
expect(response).to have_http_status(:success)
22+
23+
result = json[:subscriptions].sole
24+
expect(result[:lago_id]).to eq(subscription.id)
25+
expect(result[:plan_code]).to eq(plan.code)
26+
expect(result[:subscription_product_items_count]).to eq(1)
27+
expect(result).not_to have_key(:current_billing_period_started_at)
28+
expect(result).not_to have_key(:plan_amount_cents)
29+
end
30+
end
31+
32+
describe "GET /api/v2/subscriptions/:external_id" do
33+
subject { get_with_token(organization, "/api/v2/subscriptions/#{subscription.external_id}") }
34+
35+
let!(:subscription_product_item) { create(:subscription_product_item, organization:, subscription:) }
36+
37+
include_examples "requires API permission", "subscription", "read"
38+
39+
it "returns the subscription with its rate card entries" do
40+
subject
41+
42+
expect(response).to have_http_status(:success)
43+
expect(json[:subscription][:lago_id]).to eq(subscription.id)
44+
expect(json[:subscription][:subscription_product_items].sole[:lago_id]).to eq(subscription_product_item.id)
45+
end
46+
47+
context "when it does not exist" do
48+
subject { get_with_token(organization, "/api/v2/subscriptions/unknown") }
49+
50+
it "returns a not found error" do
51+
subject
52+
53+
expect(response).to be_not_found_error("subscription")
54+
end
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)