Skip to content

Commit bca810f

Browse files
committed
PC-26: Update quote item model structure and add tests for it
1 parent 09b483e commit bca810f

2 files changed

Lines changed: 167 additions & 64 deletions

File tree

app/models/quote_item.rb

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ class QuoteItem < ApplicationRecord
44
belongs_to :quote
55
belongs_to :item
66

7-
store_accessor :pricing_parameters, :fixed_parameters, :open_parameters_label, :pricing_options
8-
97
validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }
108
validates :discount, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
119
validates :final_price, presence: true, numericality: { greater_than_or_equal_to: 0 }
@@ -14,39 +12,28 @@ class QuoteItem < ApplicationRecord
1412
before_validation :calculate_price_from_formula, if: -> { item_requires_formula? }
1513
before_validation :calculate_final_price, if: -> { price.present? && discount.present? }
1614

15+
after_save :recalculate_quote_total_price
16+
after_destroy :recalculate_quote_total_price
17+
1718
def compile_pricing_parameters
1819
return unless item
1920

20-
combined = {}
21-
22-
combined.merge!(item.fixed_parameters || {})
23-
(open_param_values || {}).each do |k, v|
24-
combined[k] = v
25-
end
26-
27-
(select_param_values || {}).each do |k, v|
28-
combined[k] = v
29-
end
30-
31-
self.pricing_parameters = combined
21+
self.pricing_parameters = (item.fixed_parameters || {})
22+
.merge(open_param_values || {})
23+
.merge(select_param_values || {})
3224
end
3325

3426
def calculate_price_from_formula
3527
return unless item_requires_formula?
3628

3729
calculator = Dentaku::Calculator.new
38-
formula = item.calculation_formula
39-
40-
self.price = calculator.evaluate(formula, pricing_parameters)
30+
self.price = calculator.evaluate(item.calculation_formula, pricing_parameters)
4131
rescue Dentaku::UnboundVariableError => e
4232
errors.add(:price, "missing variable(s): #{e.unbound_variables.join(', ')}")
4333
rescue StandardError => e
4434
errors.add(:price, "could not calculate price: #{e.message}")
4535
end
4636

47-
after_save :recalculate_quote_total_price
48-
after_destroy :recalculate_quote_total_price
49-
5037
private
5138

5239
def calculate_final_price

spec/models/quote_item_spec.rb

Lines changed: 160 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,80 +9,196 @@
99
describe 'validations' do
1010
it { is_expected.to validate_presence_of(:price) }
1111
it { is_expected.to validate_numericality_of(:price).is_greater_than_or_equal_to(0) }
12-
13-
it do
14-
is_expected.to validate_numericality_of(:discount).is_greater_than_or_equal_to(0).is_less_than_or_equal_to(100)
15-
end
16-
12+
it { is_expected.to validate_numericality_of(:discount).is_greater_than_or_equal_to(0).is_less_than_or_equal_to(100) }
1713
it { is_expected.to validate_presence_of(:final_price) }
1814
it { is_expected.to validate_numericality_of(:final_price).is_greater_than_or_equal_to(0) }
15+
16+
context 'boundary values' do
17+
subject(:quote_item) { build(:quote_item, price: 100, discount: 10) }
18+
19+
it 'is valid with price = 0' do
20+
subject.price = 0
21+
subject.valid?
22+
expect(subject).to be_valid
23+
end
24+
25+
it 'is invalid with price < 0' do
26+
subject.price = -1
27+
subject.valid?
28+
expect(subject).not_to be_valid
29+
expect(subject.errors[:price]).to include("must be greater than or equal to 0")
30+
end
31+
32+
it 'is valid with discount = 0' do
33+
subject.discount = 0
34+
expect(subject).to be_valid
35+
end
36+
37+
it 'is valid with discount = 100' do
38+
subject.discount = 100
39+
expect(subject).to be_valid
40+
end
41+
42+
it 'is invalid with discount < 0' do
43+
subject.discount = -0.1
44+
expect(subject).not_to be_valid
45+
expect(subject.errors[:discount]).to include("must be greater than or equal to 0")
46+
end
47+
48+
it 'is invalid with discount > 100' do
49+
subject.discount = 100.1
50+
expect(subject).not_to be_valid
51+
expect(subject.errors[:discount]).to include("must be less than or equal to 100")
52+
end
53+
54+
it 'is valid with final_price = 0' do
55+
allow(subject).to receive(:calculate_final_price)
56+
subject.final_price = 0
57+
expect(subject).to be_valid
58+
end
59+
60+
it 'is invalid with final_price < 0' do
61+
allow(subject).to receive(:calculate_final_price)
62+
subject.final_price = -1
63+
expect(subject).not_to be_valid
64+
expect(subject.errors[:final_price]).to include("must be greater than or equal to 0")
65+
end
66+
end
1967
end
2068

21-
describe 'before_save callbacks' do
69+
describe 'before_validation callbacks' do
2270
let(:quote) { create(:quote) }
2371
let(:item) { create(:item) }
2472
let(:price) { 1000 }
2573
let(:discount) { 10 }
2674

27-
context 'when quote_item is created' do
28-
let(:quote_item) do
29-
QuoteItem.new(quote: quote, item: item, price: price, discount: discount)
75+
describe '#compile_pricing_parameters' do
76+
let(:quote_item) { build(:quote_item, quote: quote, item: item) }
77+
78+
it 'combines fixed, open, and select parameters correctly' do
79+
item.update(fixed_parameters: { 'platform_fee' => '1000' })
80+
quote_item.open_param_values = { 'setup' => '2500' }
81+
quote_item.select_param_values = { 'tier' => '500' }
82+
83+
quote_item.valid?
84+
85+
expect(quote_item.pricing_parameters).to eq({
86+
'platform_fee' => '1000', 'setup' => '2500', 'tier' => '500'
87+
})
3088
end
3189

32-
it 'calculates final_price before saving' do
33-
quote_item.save
34-
expect(quote_item.final_price).to eq(900)
90+
it 'handles nil open_param_values' do
91+
item.update(fixed_parameters: { 'platform_fee' => '1000' })
92+
quote_item.open_param_values = nil
93+
quote_item.select_param_values = { 'tier' => '500' }
94+
95+
quote_item.valid?
96+
97+
expect(quote_item.pricing_parameters).to eq({
98+
'platform_fee' => '1000', 'tier' => '500'
99+
})
35100
end
36101
end
37102

38-
context 'when quote_item is updated' do
39-
let(:quote_item) do
40-
create(:quote_item, quote: quote, item: item, price: price, discount: discount)
41-
end
103+
describe '#calculate_price_from_formula' do
104+
let(:quote_item) { build(:quote_item, quote: quote, item: item) }
42105

43-
context 'when price changes' do
44-
before do
45-
quote_item.price = BigDecimal(1200)
46-
quote_item.save
47-
end
106+
it 'evaluates the formula using Dentaku' do
107+
item.update(calculation_formula: 'users * setup', fixed_parameters: { 'users' => 10 })
108+
quote_item.open_param_values = { 'setup' => 500 }
48109

49-
it 'recalculates final_price' do
50-
expect(quote_item.final_price).to eq(1080)
51-
end
110+
quote_item.valid?
52111

53-
it 'calls calculate_final_price' do
54-
expect_any_instance_of(QuoteItem).to_not receive(:calculate_final_price)
55-
end
112+
expect(quote_item.price).to eq(5000)
56113
end
57114

58-
context 'when discount changes' do
59-
before do
60-
quote_item.discount = BigDecimal(20)
61-
quote_item.save
62-
end
115+
it 'adds error for missing variables' do
116+
item.update(calculation_formula: 'users * setup', fixed_parameters: {})
117+
quote_item.open_param_values = {}
63118

64-
it 'recalculates final_price' do
65-
expect(quote_item.final_price).to eq(800)
66-
end
119+
quote_item.valid?
67120

68-
it 'calls calculate_final_price' do
69-
expect_any_instance_of(QuoteItem).to_not receive(:calculate_final_price)
70-
end
121+
expect(quote_item.errors[:price]).to include("can't be blank")
71122
end
123+
end
72124

73-
context 'when neither price nor discount changes' do
74-
before do
125+
describe '#calculate_final_price' do
126+
let(:quote_item) { build(:quote_item, quote: quote, item: item, price: price, discount: discount) }
127+
128+
context 'when price and discount are present' do
129+
it 'calculates final_price on create' do
75130
quote_item.save
131+
expect(quote_item.final_price).to eq(900)
76132
end
77133

78-
it 'does not recalculate final_price' do
79-
expect(quote_item.final_price).to eq(900)
134+
it 'recalculates final_price when price changes' do
135+
quote_item.save
136+
quote_item.price = 1200
137+
quote_item.save
138+
expect(quote_item.final_price).to eq(1080)
80139
end
81140

82-
it 'does not recalculate final_price' do
83-
expect_any_instance_of(QuoteItem).to_not receive(:calculate_final_price)
141+
it 'recalculates final_price when discount changes' do
142+
quote_item.save
143+
quote_item.discount = 20
144+
quote_item.save
145+
expect(quote_item.final_price).to eq(800)
84146
end
85147
end
86148
end
87149
end
150+
151+
describe '#item_requires_formula?' do
152+
let(:quote_item) { build(:quote_item, item: item) }
153+
let(:item) { build(:item) }
154+
155+
it 'returns true when item has a calculation formula' do
156+
item.calculation_formula = 'users * setup'
157+
expect(quote_item.send(:item_requires_formula?)).to be true
158+
end
159+
160+
it 'returns false when item has no calculation formula' do
161+
item.calculation_formula = nil
162+
expect(quote_item.send(:item_requires_formula?)).to be false
163+
end
164+
165+
it 'returns false when item is nil' do
166+
quote_item.item = nil
167+
expect(quote_item.send(:item_requires_formula?)).to be false
168+
end
169+
end
170+
171+
describe 'pricing_parameters storage' do
172+
let(:quote) { create(:quote) }
173+
let(:item) { create(:item, fixed_parameters: { 'platform_fee' => '1000' }) }
174+
let(:quote_item) { create(:quote_item, quote: quote, item: item, open_param_values: { 'setup' => '2500' }) }
175+
176+
it 'persists pricing_parameters in the database' do
177+
quote_item.valid?
178+
quote_item.save
179+
expect(QuoteItem.find(quote_item.id).pricing_parameters).to eq({
180+
'platform_fee' => '1000', 'setup' => '2500'
181+
})
182+
end
183+
end
184+
185+
describe 'quote total price recalculation' do
186+
let(:quote) { create(:quote, total_price: 0) }
187+
let(:item) { create(:item) }
188+
189+
it 'triggers after save and updates quote total_price' do
190+
quote_item = build(:quote_item, quote: quote, item: item, price: 100, discount: 0, final_price: 100)
191+
expect(quote).to receive(:recalculate_total_price).and_call_original
192+
quote_item.save
193+
expect(quote.reload.total_price).to eq(100)
194+
end
195+
196+
it 'triggers after destroy and updates quote total_price' do
197+
quote_item = create(:quote_item, quote: quote, item: item, price: 100, discount: 0, final_price: 100)
198+
quote.update(total_price: 100)
199+
expect(quote).to receive(:recalculate_total_price).and_call_original
200+
quote_item.destroy
201+
expect(quote.reload.total_price).to eq(0)
202+
end
203+
end
88204
end

0 commit comments

Comments
 (0)