saucy 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. data/Gemfile +4 -1
  2. data/Gemfile.lock +12 -2
  3. data/README.md +75 -0
  4. data/app/controllers/accounts_controller.rb +2 -0
  5. data/app/controllers/billings_controller.rb +22 -0
  6. data/app/controllers/plans_controller.rb +12 -0
  7. data/app/models/limit.rb +5 -0
  8. data/app/models/signup.rb +20 -6
  9. data/app/views/accounts/_tab_bar.html.erb +3 -1
  10. data/app/views/accounts/edit.html.erb +6 -0
  11. data/app/views/accounts/new.html.erb +14 -4
  12. data/app/views/billings/_form.html.erb +8 -0
  13. data/app/views/billings/edit.html.erb +11 -0
  14. data/app/views/billings/show.html.erb +3 -0
  15. data/app/views/plans/_plan.html.erb +2 -0
  16. data/app/views/plans/edit.html.erb +23 -0
  17. data/config/routes.rb +2 -0
  18. data/features/run_features.feature +4 -0
  19. data/lib/generators/saucy/features/features_generator.rb +4 -0
  20. data/lib/generators/saucy/features/templates/factories.rb +14 -2
  21. data/lib/generators/saucy/features/templates/features/manage_account.feature +0 -2
  22. data/lib/generators/saucy/features/templates/features/manage_billing.feature +119 -0
  23. data/lib/generators/saucy/features/templates/features/sign_up.feature +2 -0
  24. data/lib/generators/saucy/features/templates/features/sign_up_paid.feature +76 -0
  25. data/lib/generators/saucy/features/templates/step_definitions/braintree_steps.rb +7 -0
  26. data/lib/generators/saucy/features/templates/step_definitions/html_steps.rb +22 -0
  27. data/lib/generators/saucy/features/templates/step_definitions/plan_steps.rb +9 -0
  28. data/lib/generators/saucy/features/templates/support/braintree.rb +5 -0
  29. data/lib/generators/saucy/install/templates/create_saucy_tables.rb +14 -1
  30. data/lib/generators/saucy/specs/specs_generator.rb +20 -0
  31. data/lib/generators/saucy/specs/templates/support/braintree.rb +5 -0
  32. data/lib/saucy/account.rb +134 -1
  33. data/lib/saucy/braintree.rb +100 -0
  34. data/lib/saucy/plan.rb +23 -0
  35. data/spec/controllers/accounts_controller_spec.rb +2 -2
  36. data/spec/environment.rb +4 -1
  37. data/spec/models/account_spec.rb +182 -0
  38. data/spec/models/limit_spec.rb +9 -0
  39. data/spec/models/plan_spec.rb +54 -0
  40. data/spec/models/signup_spec.rb +9 -0
  41. data/spec/support/braintree.rb +5 -0
  42. metadata +74 -8
  43. data/README +0 -38
@@ -0,0 +1,100 @@
1
+ require 'braintree'
2
+
3
+ Braintree::Configuration.environment = :production
4
+ Braintree::Configuration.merchant_id = "xxx"
5
+ Braintree::Configuration.public_key = "xxx"
6
+ Braintree::Configuration.private_key = "xxx"
7
+
8
+ require 'digest/md5'
9
+ require 'sham_rack'
10
+
11
+ class FakeBraintree
12
+ cattr_accessor :customers, :subscriptions, :failures
13
+ @@customers = {}
14
+ @@subscriptions = {}
15
+ @@failures = {}
16
+
17
+ def self.clear!
18
+ @@customers = {}
19
+ @@subscriptions = {}
20
+ @@failures = {}
21
+ end
22
+
23
+ def self.failure?(card_number)
24
+ self.failures.include?(card_number)
25
+ end
26
+
27
+ def self.failure_response(card_number)
28
+ failure = self.failures[card_number]
29
+ failure["errors"] ||= { "errors" => [] }
30
+ { "message" => failure["message"], "verification" => { "status" => failure["status"], "processor_response_text" => failure["message"], "processor-response-code" => failure["code"], "gateway_rejection_reason" => "cvv", "cvv_response_code" => failure["code"] }, "errors" => failure["errors"], "params" => {}}
31
+ end
32
+ end
33
+
34
+ ShamRack.at("www.braintreegateway.com", 443).sinatra do
35
+ set :show_exceptions, false
36
+ set :dump_errors, true
37
+ set :raise_errors, true
38
+
39
+ post "/merchants/:merchant_id/customers" do
40
+ customer = Hash.from_xml(request.body).delete("customer")
41
+ if !FakeBraintree.failure?(customer["credit_card"]["number"])
42
+ customer["id"] ||= Digest::MD5.hexdigest("#{params[:merchant_id]}#{Time.now.to_f}")
43
+ customer["merchant-id"] = params[:merchant_id]
44
+ if customer["credit_card"] && customer["credit_card"].is_a?(Hash)
45
+ customer["credit_card"]["last_4"] = customer["credit_card"].delete("number")[-4..-1]
46
+ customer["credit_card"]["token"] = Digest::MD5.hexdigest("#{customer['merchant_id']}#{customer['id']}#{Time.now.to_f}")
47
+ credit_card = customer.delete("credit_card")
48
+ customer["credit_cards"] = [credit_card]
49
+ end
50
+ FakeBraintree.customers[customer["id"]] = customer
51
+ [201, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(customer.to_xml(:root => 'customer'))]
52
+ else
53
+ [422, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(FakeBraintree.failure_response(customer["credit_card"]["number"]).to_xml(:root => 'api_error_response'))]
54
+ end
55
+ end
56
+
57
+ get "/merchants/:merchant_id/customers/:id" do
58
+ customer = FakeBraintree.customers[params[:id]]
59
+ [200, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(customer.to_xml(:root => 'customer'))]
60
+ end
61
+
62
+ put "/merchants/:merchant_id/customers/:id" do
63
+ customer = Hash.from_xml(request.body).delete("customer")
64
+ customer["id"] = params[:id]
65
+ customer["merchant-id"] = params[:merchant_id]
66
+ if customer["credit_card"] && customer["credit_card"].is_a?(Hash)
67
+ customer["credit_card"]["last_4"] = customer["credit_card"].delete("number")[-4..-1]
68
+ customer["credit_card"]["token"] = Digest::MD5.hexdigest("#{customer['merchant_id']}#{customer['id']}#{Time.now.to_f}")
69
+ credit_card = customer.delete("credit_card")
70
+ customer["credit_cards"] = [credit_card]
71
+ end
72
+ FakeBraintree.customers[params["id"]] = customer
73
+ [200, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(customer.to_xml(:root => 'customer'))]
74
+ end
75
+
76
+ post "/merchants/:merchant_id/subscriptions" do
77
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<subscription>\n <plan-id type=\"integer\">2</plan-id>\n <payment-method-token>b22x</payment-method-token>\n</subscription>\n"
78
+ subscription = Hash.from_xml(request.body).delete("subscription")
79
+ subscription["id"] ||= Digest::MD5.hexdigest("#{subscription["payment_method_token"]}#{Time.now.to_f}")
80
+ subscription["transactions"] = []
81
+ subscription["add_ons"] = []
82
+ subscription["discounts"] = []
83
+ FakeBraintree.subscriptions[subscription["id"]] = subscription
84
+ [201, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(subscription.to_xml(:root => 'subscription'))]
85
+ end
86
+
87
+ get "/merchants/:merchant_id/subscriptions/:id" do
88
+ subscription = FakeBraintree.subscriptions[params[:id]]
89
+ [200, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(subscription.to_xml(:root => 'subscription'))]
90
+ end
91
+
92
+ put "/merchants/:merchant_id/subscriptions/:id" do
93
+ subscription = Hash.from_xml(request.body).delete("subscription")
94
+ subscription["transactions"] = []
95
+ subscription["add_ons"] = []
96
+ subscription["discounts"] = []
97
+ FakeBraintree.subscriptions[params["id"]] = subscription
98
+ [200, { "Content-Encoding" => "gzip" }, ActiveSupport::Gzip.compress(subscription.to_xml(:root => 'subscription'))]
99
+ end
100
+ end
data/lib/saucy/plan.rb CHANGED
@@ -4,8 +4,31 @@ module Saucy
4
4
 
5
5
  included do
6
6
  has_many :accounts
7
+ has_many :limits
7
8
 
8
9
  validates_presence_of :name
9
10
  end
11
+
12
+ module InstanceMethods
13
+ def free?
14
+ price.zero?
15
+ end
16
+
17
+ def billed?
18
+ !free?
19
+ end
20
+
21
+ def can_add_more?(limit, amount)
22
+ limits.where(:name => limit, :value_type => :number).first.value > amount
23
+ end
24
+
25
+ def allows?(limit)
26
+ limits.where(:name => limit, :value_type => :boolean).first.value != 0
27
+ end
28
+
29
+ def limit(limit_name)
30
+ limits.where(:name => limit_name).first
31
+ end
32
+ end
10
33
  end
11
34
  end
@@ -32,7 +32,7 @@ end
32
32
 
33
33
  describe AccountsController, "successful create for a confirmed user" do
34
34
  let(:user) { Factory.stub(:user) }
35
- let(:signup) { stub('signup', :user => user, :user= => nil) }
35
+ let(:signup) { stub('signup', :user => user, :user= => nil, :plan= => plan) }
36
36
  let(:signup_attributes) { "attributes" }
37
37
  let(:plan) { Factory(:plan) }
38
38
 
@@ -62,7 +62,7 @@ describe AccountsController, "successful create for a confirmed user" do
62
62
  end
63
63
 
64
64
  describe AccountsController, "failed create" do
65
- let(:signup) { stub('signup', :user= => nil) }
65
+ let(:signup) { stub('signup', :user= => nil, :plan= => plan) }
66
66
  let(:signup_attributes) { "attributes" }
67
67
  let(:plan) { Factory(:plan) }
68
68
 
data/spec/environment.rb CHANGED
@@ -23,6 +23,10 @@ class User < ActiveRecord::Base
23
23
  include Saucy::User
24
24
  end
25
25
 
26
+ class Plan < ActiveRecord::Base
27
+ include Saucy::Plan
28
+ end
29
+
26
30
  module Testapp
27
31
  class Application < Rails::Application
28
32
  config.action_mailer.default_url_options = { :host => 'localhost' }
@@ -89,4 +93,3 @@ end
89
93
 
90
94
  ClearanceCreateUsers.suppress_messages { ClearanceCreateUsers.migrate(:up) }
91
95
  CreateSaucyTables.suppress_messages { CreateSaucyTables.migrate(:up) }
92
-
@@ -6,11 +6,13 @@ describe Account do
6
6
  it { should have_many(:memberships) }
7
7
  it { should have_many(:users).through(:memberships) }
8
8
  it { should have_many(:projects) }
9
+ it { should belong_to(:plan) }
9
10
 
10
11
  it { should validate_uniqueness_of(:name) }
11
12
  it { should validate_uniqueness_of(:keyword) }
12
13
  it { should validate_presence_of( :name) }
13
14
  it { should validate_presence_of(:keyword) }
15
+ it { should validate_presence_of(:plan_id) }
14
16
 
15
17
  it { should_not allow_mass_assignment_of(:id) }
16
18
  it { should_not allow_mass_assignment_of(:updated_at) }
@@ -63,5 +65,185 @@ describe Account do
63
65
 
64
66
  result.should == expected
65
67
  end
68
+
69
+ it "manifests braintree processor_declined errors as errors on number and doesn't save" do
70
+ FakeBraintree.failures["4111111111111112"] = { "message" => "Do Not Honor", "code" => "2000", "status" => "processor_declined" }
71
+ account = Factory.build(:account,
72
+ :cardholder_name => "Ralph Robot",
73
+ :billing_email => "ralph@example.com",
74
+ :card_number => "4111111111111112",
75
+ :expiration_month => 5,
76
+ :expiration_year => 2012,
77
+ :plan => Factory(:paid_plan))
78
+ account.save.should_not be
79
+ FakeBraintree.customers.should be_empty
80
+ account.persisted?.should_not be
81
+ account.errors[:card_number].any? { |e| e =~ /denied/ }.should be
82
+ end
83
+
84
+ it "manifests braintree gateway_rejected errors as errors on number and doesn't save" do
85
+ FakeBraintree.failures["4111111111111112"] = { "message" => "Gateway Rejected: cvv", "code" => "N", "status" => "gateway_rejected" }
86
+ account = Factory.build(:account,
87
+ :cardholder_name => "Ralph Robot",
88
+ :billing_email => "ralph@example.com",
89
+ :card_number => "4111111111111112",
90
+ :expiration_month => 5,
91
+ :expiration_year => 2012,
92
+ :verification_code => 200,
93
+ :plan => Factory(:paid_plan))
94
+ account.save.should_not be
95
+ FakeBraintree.customers.should be_empty
96
+ account.persisted?.should_not be
97
+ account.errors[:verification_code].any? { |e| e =~ /did not match/ }.should be
98
+ end
99
+
100
+ it "manifests braintree gateway_rejected errors as errors on number and doesn't save" do
101
+ FakeBraintree.failures["4111111111111111"] = { "message" => "Credit card number is invalid.", "errors" => { "customer" => { "errors" => [], "credit-card" => { "errors" => [{ "message" => "Credit card number is invalid.", "code" => 81715, "attribute" => :number }] }}}}
102
+ account = Factory.build(:account,
103
+ :cardholder_name => "Ralph Robot",
104
+ :billing_email => "ralph@example.com",
105
+ :card_number => "4111111111111111",
106
+ :expiration_month => 5,
107
+ :expiration_year => 2012,
108
+ :verification_code => 123,
109
+ :plan => Factory(:paid_plan))
110
+ account.save.should_not be
111
+ FakeBraintree.customers.should be_empty
112
+ account.persisted?.should_not be
113
+ account.errors[:card_number].any? { |e| e =~ /is invalid/ }.should be
114
+ end
115
+ end
116
+
117
+ describe Account, "with a paid plan" do
118
+ subject do
119
+ Factory(:account,
120
+ :cardholder_name => "Ralph Robot",
121
+ :billing_email => "ralph@example.com",
122
+ :card_number => "4111111111111111",
123
+ :verification_code => "123",
124
+ :expiration_month => 5,
125
+ :expiration_year => 2012,
126
+ :plan => Factory(:paid_plan))
127
+ end
128
+
129
+ it "has a customer_token" do
130
+ subject.customer_token.should_not be_nil
131
+ end
132
+
133
+ it "has a subscription_token" do
134
+ subject.subscription_token.should_not be_nil
135
+ end
136
+
137
+ it "has a customer" do
138
+ subject.customer.should_not be_nil
139
+ end
140
+
141
+ it "has a credit card" do
142
+ subject.credit_card.should_not be_nil
143
+ end
144
+
145
+ it "has a subscription" do
146
+ subject.subscription.should_not be_nil
147
+ end
148
+
149
+ it "creates a braintree customer, credit card, and subscription" do
150
+ FakeBraintree.customers[subject.customer_token].should_not be_nil
151
+ FakeBraintree.customers[subject.customer_token]["credit_cards"].first.should_not be_nil
152
+ FakeBraintree.subscriptions[subject.subscription_token].should_not be_nil
153
+ end
154
+
155
+ it "changes the subscription when the plan is changed" do
156
+ new_plan = Factory(:paid_plan, :name => "New Plan")
157
+ subject.save_braintree!(:plan_id => new_plan.id)
158
+ FakeBraintree.subscriptions[subject.subscription_token]["plan_id"].should == new_plan.id
159
+ end
160
+
161
+ it "updates the customer and credit card information when changed" do
162
+ subject.save_braintree!(:billing_email => "jrobot@example.com",
163
+ :cardholder_name => "Jim Robot",
164
+ :card_number => "4111111111111115",
165
+ :verification_code => "123",
166
+ :expiration_month => 5,
167
+ :expiration_year => 2013)
168
+ subject.customer.email.should == "jrobot@example.com"
169
+ subject.credit_card.cardholder_name.should == "Jim Robot"
170
+ end
66
171
  end
67
172
 
173
+ describe Account, "with a free plan" do
174
+ subject do
175
+ Factory(:account, :plan => Factory(:plan))
176
+ end
177
+
178
+ it "has a customer_token" do
179
+ subject.customer_token.should_not be_nil
180
+ end
181
+
182
+ it "has a customer" do
183
+ subject.customer.should_not be_nil
184
+ end
185
+
186
+ it "doesn't have a credit_card" do
187
+ subject.credit_card.should be_nil
188
+ end
189
+
190
+ it "doesn't have a subscription_token" do
191
+ subject.subscription_token.should be_nil
192
+ end
193
+
194
+ it "doesn't have a subscription" do
195
+ subject.subscription.should be_nil
196
+ end
197
+
198
+ it "creates a braintree customer" do
199
+ FakeBraintree.customers[subject.customer_token].should_not be_nil
200
+ end
201
+
202
+ it "doesn't create a credit card, and subscription" do
203
+ FakeBraintree.customers[subject.customer_token]["credit_cards"].should be_nil
204
+ FakeBraintree.subscriptions[subject.subscription_token].should be_nil
205
+ end
206
+
207
+ it "creates a credit card, and subscription when the plan is changed and billing info is supplied" do
208
+ new_plan = Factory(:paid_plan, :name => "New Plan")
209
+ subject.save_braintree!(:plan_id => new_plan.id,
210
+ :cardholder_name => "Ralph Robot",
211
+ :billing_email => "ralph@example.com",
212
+ :card_number => "4111111111111111",
213
+ :verification_code => "123",
214
+ :expiration_month => 5,
215
+ :expiration_year => 2012)
216
+
217
+ FakeBraintree.customers[subject.customer_token]["credit_cards"].first.should_not be_nil
218
+ FakeBraintree.subscriptions[subject.subscription_token].should_not be_nil
219
+ FakeBraintree.subscriptions[subject.subscription_token]["plan_id"].should == new_plan.id
220
+ subject.credit_card.should_not be_nil
221
+ subject.subscription.should_not be_nil
222
+ end
223
+ end
224
+
225
+ describe Account, "with a plan and limits, and other plans" do
226
+ subject { Factory(:account) }
227
+
228
+ before do
229
+ Factory(:limit, :name => "users", :value => 1, :plan => subject.plan)
230
+ Factory(:limit, :name => "projects", :value => 1, :plan => subject.plan)
231
+ Factory(:limit, :name => "ssl", :value => 1, :value_type => :boolean, :plan => subject.plan)
232
+ @can_switch = Factory(:plan)
233
+ Factory(:limit, :name => "users", :value => 1, :plan => @can_switch)
234
+ Factory(:limit, :name => "projects", :value => 1, :plan => @can_switch)
235
+ Factory(:limit, :name => "ssl", :value => 0, :value_type => :boolean, :plan => @can_switch)
236
+ @cannot_switch = Factory(:plan)
237
+ Factory(:limit, :name => "users", :value => 0, :plan => @cannot_switch)
238
+ Factory(:limit, :name => "projects", :value => 0, :plan => @cannot_switch)
239
+ Factory(:limit, :name => "ssl", :value => 1, :value_type => :boolean, :plan => @cannot_switch)
240
+
241
+ Factory(:membership, :account => subject)
242
+ Factory(:project, :account => subject)
243
+ end
244
+
245
+ it "indicates whether the account can switch to another plan" do
246
+ subject.can_change_plan_to?(@can_switch).should be
247
+ subject.can_change_plan_to?(@cannot_switch).should_not be
248
+ end
249
+ end
@@ -0,0 +1,9 @@
1
+ require 'spec_helper'
2
+
3
+ describe Limit do
4
+ subject { Factory(:limit) }
5
+
6
+ it { should belong_to(:plan) }
7
+ it { should validate_presence_of(:name) }
8
+ it { should validate_presence_of(:value) }
9
+ end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+
3
+ describe Plan do
4
+ subject { Factory(:plan) }
5
+
6
+ it { should have_many(:limits) }
7
+ it { should have_many(:accounts) }
8
+ it { should validate_presence_of(:name) }
9
+ end
10
+
11
+ describe Plan, "free" do
12
+ subject { Factory(:plan) }
13
+
14
+ it "is free" do
15
+ subject.free?.should be
16
+ end
17
+
18
+ it "is not billed" do
19
+ subject.billed?.should_not be
20
+ end
21
+ end
22
+
23
+ describe Plan, "paid" do
24
+ subject { Factory(:paid_plan) }
25
+
26
+ it "is not free" do
27
+ subject.free?.should_not be
28
+ end
29
+
30
+ it "is billed" do
31
+ subject.billed?.should be
32
+ end
33
+ end
34
+
35
+ describe Plan, "with limits" do
36
+ subject { Factory(:plan) }
37
+
38
+ before do
39
+ Factory(:limit, :name => "users", :value => 1, :plan => subject)
40
+ Factory(:limit, :name => "ssl", :value => 0, :value_type => :boolean, :plan => subject)
41
+ Factory(:limit, :name => "lighthouse", :value => 1, :value_type => :boolean, :plan => subject)
42
+ end
43
+
44
+ it "indicates whether or not more users can be created" do
45
+ subject.can_add_more?(:users, 0).should be
46
+ subject.can_add_more?(:users, 1).should_not be
47
+ subject.can_add_more?(:users, 2).should_not be
48
+ end
49
+
50
+ it "indicates whether a plan can do something or not" do
51
+ subject.allows?(:ssl).should_not be
52
+ subject.allows?(:lighthouse).should be
53
+ end
54
+ end
@@ -173,3 +173,12 @@ describe Signup, "invalid with an existing user and correct password" do
173
173
  user.reload.accounts.should_not include(subject.account)
174
174
  end
175
175
  end
176
+
177
+ describe Signup, "with an account that doesn't save" do
178
+ subject { Factory.build(:signup) }
179
+
180
+ it "doesn't raise the transaction and returns false" do
181
+ Account.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotSaved)
182
+ subject.save.should_not be
183
+ end
184
+ end