Skip to content

Commit f74030f

Browse files
committed
feat(products): add product item API endpoints
Add /api/v1/product_items CRUD endpoints backed by the product item services and query. Items are addressed by id since their code is only unique within a product (or the organization for standalone items). Create accepts product_id and billable_metric_id; update only permits name, description and invoice display name. Index supports product_id and item_type filters with pagination.
1 parent f43e6b2 commit f74030f

4 files changed

Lines changed: 288 additions & 1 deletion

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
class ProductItemsController < Api::BaseController
6+
def create
7+
result = ProductItems::CreateService.call(
8+
organization: current_organization,
9+
params: input_params.to_h.symbolize_keys
10+
)
11+
12+
if result.success?
13+
render_product_item(result.product_item)
14+
else
15+
render_error_response(result)
16+
end
17+
end
18+
19+
def update
20+
product_item = current_organization.product_items.find_by(id: params[:id])
21+
result = ProductItems::UpdateService.call(product_item:, params: update_params.to_h.symbolize_keys)
22+
23+
if result.success?
24+
render_product_item(result.product_item)
25+
else
26+
render_error_response(result)
27+
end
28+
end
29+
30+
def destroy
31+
product_item = current_organization.product_items.find_by(id: params[:id])
32+
result = ProductItems::DestroyService.call(product_item:)
33+
34+
if result.success?
35+
render_product_item(result.product_item)
36+
else
37+
render_error_response(result)
38+
end
39+
end
40+
41+
def show
42+
product_item = current_organization.product_items.find_by(id: params[:id])
43+
44+
return not_found_error(resource: "product_item") unless product_item
45+
46+
render_product_item(product_item)
47+
end
48+
49+
def index
50+
result = ProductItemsQuery.call(
51+
organization: current_organization,
52+
pagination: {
53+
page: params[:page],
54+
limit: params[:per_page] || PER_PAGE
55+
},
56+
filters: {
57+
product_id: params[:product_id],
58+
item_types: params[:item_type].presence&.then { [it] }
59+
}
60+
)
61+
62+
if result.success?
63+
render(
64+
json: ::CollectionSerializer.new(
65+
result.product_items,
66+
::V1::ProductItemSerializer,
67+
collection_name: "product_items",
68+
meta: pagination_metadata(result.product_items)
69+
)
70+
)
71+
else
72+
render_error_response(result)
73+
end
74+
end
75+
76+
private
77+
78+
def input_params
79+
params.require(:product_item).permit(
80+
:name,
81+
:code,
82+
:description,
83+
:invoice_display_name,
84+
:item_type,
85+
:product_id,
86+
:billable_metric_id
87+
)
88+
end
89+
90+
def update_params
91+
params.require(:product_item).permit(
92+
:name,
93+
:description,
94+
:invoice_display_name
95+
)
96+
end
97+
98+
def render_product_item(product_item)
99+
render(json: ::V1::ProductItemSerializer.new(product_item, root_name: "product_item"))
100+
end
101+
102+
def resource_name
103+
"product_item"
104+
end
105+
end
106+
end
107+
end

app/models/api_key.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class ApiKey < ApplicationRecord
77
activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage
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
10-
billing_entity alert feature security_log quote product
10+
billing_entity alert feature security_log quote product product_item
1111
].freeze
1212

1313
MODES = %w[read write].freeze

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
end
180180
end
181181
end
182+
resources :product_items, only: %i[index show create update destroy]
182183
resources :products, param: :code, code: /.*/, only: %i[index show create update destroy]
183184
resources :taxes, param: :code, code: /.*/
184185
resources :wallet_transactions, only: %i[create show] do
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Api::V1::ProductItemsController do
6+
let(:organization) { create(:organization) }
7+
8+
describe "POST /api/v1/product_items" do
9+
subject { post_with_token(organization, "/api/v1/product_items", {product_item: create_params}) }
10+
11+
let(:product) { create(:product, organization:) }
12+
let(:billable_metric) { create(:billable_metric, organization:) }
13+
14+
let(:create_params) do
15+
{
16+
name: "Storage",
17+
code: "storage",
18+
item_type: "usage",
19+
product_id: product.id,
20+
billable_metric_id: billable_metric.id
21+
}
22+
end
23+
24+
include_examples "requires API permission", "product_item", "write"
25+
26+
it "creates a product item" do
27+
subject
28+
29+
expect(response).to have_http_status(:success)
30+
expect(json[:product_item][:lago_id]).to be_present
31+
expect(json[:product_item][:name]).to eq("Storage")
32+
expect(json[:product_item][:code]).to eq("storage")
33+
expect(json[:product_item][:item_type]).to eq("usage")
34+
expect(json[:product_item][:lago_product_id]).to eq(product.id)
35+
expect(json[:product_item][:lago_billable_metric_id]).to eq(billable_metric.id)
36+
end
37+
38+
context "with a standalone fixed item" do
39+
let(:create_params) { {name: "Seats", code: "seats", item_type: "fixed"} }
40+
41+
it "creates the item without product nor metric" do
42+
subject
43+
44+
expect(response).to have_http_status(:success)
45+
expect(json[:product_item][:item_type]).to eq("fixed")
46+
expect(json[:product_item][:lago_product_id]).to be_nil
47+
expect(json[:product_item][:lago_billable_metric_id]).to be_nil
48+
end
49+
end
50+
51+
context "when the billable metric belongs to another organization" do
52+
let(:billable_metric) { create(:billable_metric) }
53+
54+
it "returns a not found error" do
55+
subject
56+
57+
expect(response).to be_not_found_error("billable_metric")
58+
end
59+
end
60+
end
61+
62+
describe "PUT /api/v1/product_items/:id" do
63+
subject { put_with_token(organization, "/api/v1/product_items/#{product_item.id}", {product_item: update_params}) }
64+
65+
let(:product_item) { create(:product_item, organization:, name: "Before") }
66+
let(:update_params) { {name: "After"} }
67+
68+
include_examples "requires API permission", "product_item", "write"
69+
70+
it "updates the product item" do
71+
subject
72+
73+
expect(response).to have_http_status(:success)
74+
expect(json[:product_item][:name]).to eq("After")
75+
expect(json[:product_item][:code]).to eq(product_item.code)
76+
end
77+
78+
context "when the product item does not exist" do
79+
subject { put_with_token(organization, "/api/v1/product_items/#{SecureRandom.uuid}", {product_item: update_params}) }
80+
81+
it "returns a not found error" do
82+
subject
83+
84+
expect(response).to be_not_found_error("product_item")
85+
end
86+
end
87+
end
88+
89+
describe "GET /api/v1/product_items/:id" do
90+
subject { get_with_token(organization, "/api/v1/product_items/#{product_item.id}") }
91+
92+
let(:product_item) { create(:product_item, organization:) }
93+
94+
include_examples "requires API permission", "product_item", "read"
95+
96+
it "returns the product item" do
97+
subject
98+
99+
expect(response).to have_http_status(:success)
100+
expect(json[:product_item][:lago_id]).to eq(product_item.id)
101+
expect(json[:product_item][:code]).to eq(product_item.code)
102+
end
103+
104+
context "when the product item belongs to another organization" do
105+
let(:product_item) { create(:product_item) }
106+
107+
it "returns a not found error" do
108+
subject
109+
110+
expect(response).to be_not_found_error("product_item")
111+
end
112+
end
113+
end
114+
115+
describe "GET /api/v1/product_items" do
116+
subject { get_with_token(organization, "/api/v1/product_items#{query_params}") }
117+
118+
let(:query_params) { "" }
119+
let(:product) { create(:product, organization:) }
120+
let!(:usage_item) { create(:product_item, organization:, product:) }
121+
let!(:fixed_item) { create(:product_item, :fixed, :standalone, organization:) }
122+
123+
include_examples "requires API permission", "product_item", "read"
124+
125+
it "returns the product items" do
126+
subject
127+
128+
expect(response).to have_http_status(:success)
129+
expect(json[:product_items].map { it[:lago_id] }).to match_array([usage_item.id, fixed_item.id])
130+
expect(json[:meta][:total_count]).to eq(2)
131+
end
132+
133+
context "with an item_type filter" do
134+
let(:query_params) { "?item_type=fixed" }
135+
136+
it "returns only matching items" do
137+
subject
138+
139+
expect(json[:product_items].map { it[:lago_id] }).to eq([fixed_item.id])
140+
end
141+
end
142+
143+
context "with a product_id filter" do
144+
let(:query_params) { "?product_id=#{product.id}" }
145+
146+
it "returns only the items of that product" do
147+
subject
148+
149+
expect(json[:product_items].map { it[:lago_id] }).to eq([usage_item.id])
150+
end
151+
end
152+
end
153+
154+
describe "DELETE /api/v1/product_items/:id" do
155+
subject { delete_with_token(organization, "/api/v1/product_items/#{product_item.id}") }
156+
157+
let(:product_item) { create(:product_item, organization:) }
158+
159+
include_examples "requires API permission", "product_item", "write"
160+
161+
it "soft deletes the product item" do
162+
subject
163+
164+
expect(response).to have_http_status(:success)
165+
expect(json[:product_item][:lago_id]).to eq(product_item.id)
166+
expect(product_item.reload).to be_discarded
167+
end
168+
169+
context "when the product item does not exist" do
170+
subject { delete_with_token(organization, "/api/v1/product_items/#{SecureRandom.uuid}") }
171+
172+
it "returns a not found error" do
173+
subject
174+
175+
expect(response).to be_not_found_error("product_item")
176+
end
177+
end
178+
end
179+
end

0 commit comments

Comments
 (0)