recurly 0.4.16 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of recurly might be problematic. Click here for more details.

Files changed (78) hide show
  1. data/README.markdown +118 -0
  2. data/bin/recurly +78 -0
  3. data/lib/rails/generators/recurly/config_generator.rb +16 -0
  4. data/lib/rails/recurly.rb +13 -0
  5. data/lib/recurly.rb +64 -139
  6. data/lib/recurly/account.rb +52 -111
  7. data/lib/recurly/add_on.rb +20 -0
  8. data/lib/recurly/adjustment.rb +51 -0
  9. data/lib/recurly/api.rb +73 -0
  10. data/lib/recurly/api/errors.rb +205 -0
  11. data/lib/recurly/api/net_http.rb +77 -0
  12. data/lib/recurly/billing_info.rb +45 -42
  13. data/lib/recurly/coupon.rb +63 -8
  14. data/lib/recurly/helper.rb +39 -0
  15. data/lib/recurly/invoice.rb +38 -16
  16. data/lib/recurly/js.rb +113 -0
  17. data/lib/recurly/money.rb +105 -0
  18. data/lib/recurly/plan.rb +26 -15
  19. data/lib/recurly/redemption.rb +34 -0
  20. data/lib/recurly/resource.rb +925 -0
  21. data/lib/recurly/resource/pager.rb +210 -0
  22. data/lib/recurly/subscription.rb +90 -67
  23. data/lib/recurly/subscription/add_ons.rb +73 -0
  24. data/lib/recurly/transaction.rb +65 -53
  25. data/lib/recurly/transaction/errors.rb +98 -0
  26. data/lib/recurly/version.rb +16 -2
  27. data/lib/recurly/xml.rb +85 -0
  28. data/lib/recurly/xml/nokogiri.rb +49 -0
  29. data/lib/recurly/xml/rexml.rb +50 -0
  30. metadata +76 -165
  31. data/LICENSE +0 -21
  32. data/README.md +0 -104
  33. data/init.rb +0 -1
  34. data/lib/patches/rails2/active_resource/base.rb +0 -35
  35. data/lib/patches/rails2/active_resource/connection.rb +0 -10
  36. data/lib/patches/rails3/active_model/serializers/xml.rb +0 -28
  37. data/lib/patches/rails3/active_resource/connection.rb +0 -10
  38. data/lib/recurly/account_base.rb +0 -35
  39. data/lib/recurly/base.rb +0 -195
  40. data/lib/recurly/charge.rb +0 -39
  41. data/lib/recurly/config_parser.rb +0 -31
  42. data/lib/recurly/credit.rb +0 -28
  43. data/lib/recurly/exceptions.rb +0 -32
  44. data/lib/recurly/formats/xml_with_errors.rb +0 -132
  45. data/lib/recurly/formats/xml_with_pagination.rb +0 -47
  46. data/lib/recurly/rails2/compatibility.rb +0 -8
  47. data/lib/recurly/rails3/railtie.rb +0 -21
  48. data/lib/recurly/rails3/recurly.rake +0 -28
  49. data/lib/recurly/transparent.rb +0 -148
  50. data/lib/recurly/verification.rb +0 -83
  51. data/spec/config/recurly.yml +0 -6
  52. data/spec/config/test1.yml +0 -4
  53. data/spec/config/test2.yml +0 -7
  54. data/spec/integration/account_spec.rb +0 -286
  55. data/spec/integration/add_on_spec.rb +0 -84
  56. data/spec/integration/billing_info_spec.rb +0 -148
  57. data/spec/integration/charge_spec.rb +0 -176
  58. data/spec/integration/coupon_spec.rb +0 -49
  59. data/spec/integration/credit_spec.rb +0 -106
  60. data/spec/integration/invoice_spec.rb +0 -86
  61. data/spec/integration/plan_spec.rb +0 -87
  62. data/spec/integration/subscription_spec.rb +0 -221
  63. data/spec/integration/transaction_spec.rb +0 -154
  64. data/spec/integration/transparent_spec.rb +0 -99
  65. data/spec/spec_helper.rb +0 -34
  66. data/spec/support/factory.rb +0 -211
  67. data/spec/support/vcr.rb +0 -11
  68. data/spec/unit/account_spec.rb +0 -19
  69. data/spec/unit/billing_info_spec.rb +0 -39
  70. data/spec/unit/charge_spec.rb +0 -20
  71. data/spec/unit/config_spec.rb +0 -42
  72. data/spec/unit/coupon_spec.rb +0 -13
  73. data/spec/unit/credit_spec.rb +0 -20
  74. data/spec/unit/plan_spec.rb +0 -18
  75. data/spec/unit/subscription_spec.rb +0 -25
  76. data/spec/unit/transaction_spec.rb +0 -32
  77. data/spec/unit/transparent_spec.rb +0 -152
  78. data/spec/unit/verification_spec.rb +0 -82
@@ -0,0 +1,210 @@
1
+ module Recurly
2
+ class Resource
3
+ # Pages through an index resource, yielding records as it goes. It's rare
4
+ # to instantiate one on its own: use {Resource.paginate},
5
+ # {Resource.find_each}, and <tt>Resource#{has_many_association}</tt>
6
+ # instead.
7
+ #
8
+ # Because pagers handle +has_many+ associations, pagers can also build and
9
+ # create child records.
10
+ #
11
+ # @example Through a resource class:
12
+ # Recurly::Account.paginate # => #<Recurly::Resource::Pager...>
13
+ #
14
+ # Recurly::Account.find_each { |a| p a }
15
+ # @example Through an resource instance:
16
+ # account.transactions
17
+ # # => #<Recurly::Resource::Pager...>
18
+ #
19
+ # account.transactions.new(attributes) # or #create, or #create!
20
+ # # => #<Recurly::Transaction ...>
21
+ class Pager
22
+ include Enumerable
23
+
24
+ # @return [Resource] The resource class of the pager.
25
+ attr_reader :resource_class
26
+
27
+ # @return [Hash, nil] A hash of links to which the pager can page.
28
+ attr_reader :links
29
+
30
+ # @return [String, nil] An ETag for the current page.
31
+ attr_reader :etag
32
+
33
+ # A pager for paginating through resource records.
34
+ #
35
+ # @param resource_class [Resource] The resource to be paginated.
36
+ # @param options [Hash] A hash of pagination options.
37
+ # @option options [Integer] :per_page The number of records returned per
38
+ # page.
39
+ # @option options [DateTime, Time, Integer] :cursor A timestamp that the
40
+ # pager will skim back to and return records created before it.
41
+ # @option options [String] :etag When set, will raise {API::NotModified}
42
+ # if the loaded page content has not changed.
43
+ # @option options [String] :uri The default location the pager will
44
+ # request.
45
+ # @raise [API::NotModified] If the <tt>:etag</tt> option is set and
46
+ # matches the server's.
47
+ def initialize resource_class, options = {}
48
+ options[:cursor] &&= options[:cursor].to_i
49
+ @parent = options.delete :parent
50
+ @uri = options.delete :uri
51
+ @etag = options.delete :etag
52
+ @resource_class, @options = resource_class, options
53
+ @collection = @count = nil
54
+ end
55
+
56
+ # @return [String] The URI of the paginated resource.
57
+ def uri
58
+ @uri ||= resource_class.collection_path
59
+ end
60
+
61
+ # @return [Integer] The total record count of the resource in question.
62
+ # @see Resource.count
63
+ def count
64
+ @count ||= API.head(uri, @options)['X-Records'].to_i
65
+ end
66
+
67
+ # @return [Array] Iterates through the current page of records.
68
+ # @yield [record]
69
+ def each
70
+ return enum_for :each unless block_given?
71
+ load! unless @collection
72
+ @collection.each { |record| yield record }
73
+ end
74
+
75
+ # @return [nil]
76
+ # @see Resource.find_each
77
+ # @yield [record]
78
+ def find_each
79
+ return enum_for :find_each unless block_given?
80
+ begin
81
+ each { |record| yield record }
82
+ end while self.next
83
+ end
84
+
85
+ # @return [Array, nil] Refreshes the pager's collection of records with
86
+ # the next page.
87
+ def next
88
+ load_from links['next'], nil if links.key? 'next'
89
+ end
90
+
91
+ # @return [Array, nil] Refreshes the pager's collection of records with
92
+ # the first page.
93
+ def start
94
+ load_from links['start'], nil if links.key? 'start'
95
+ end
96
+
97
+ # @return [Array, nil] Load (or reload) the pager's collection from the
98
+ # original, supplied options.
99
+ def load!
100
+ load_from uri, @options
101
+ end
102
+ alias reload load!
103
+
104
+ # @return [Pager] Duplicates the pager, updating it with the options
105
+ # supplied. Useful for resource scopes.
106
+ # @see #initialize
107
+ # @example
108
+ # Recurly::Account.active.paginate :per_page => 20
109
+ def paginate options = {}
110
+ dup.instance_eval {
111
+ @collection = @count = @etag = nil
112
+ @options.update options and self
113
+ }
114
+ end
115
+ alias scoped paginate
116
+ alias where paginate
117
+
118
+ def all options = {}
119
+ paginate(options).to_a
120
+ end
121
+
122
+ # Instantiates a new record in the scope of the pager.
123
+ #
124
+ # @return [Resource] A new record.
125
+ # @example
126
+ # account = Recurly::Account.find 'schrader'
127
+ # subscription = account.subscriptions.new attributes
128
+ # @see Resource.new
129
+ def new attributes = {}
130
+ record = resource_class.send(:new, attributes) { |r|
131
+ r.attributes[@parent.class.member_name] ||= @parent if @parent
132
+ r.uri = uri
133
+ }
134
+ yield record if block_given?
135
+ record
136
+ end
137
+
138
+ # Instantiates and saves a record in the scope of the pager.
139
+ #
140
+ # @return [Resource] The record.
141
+ # @raise [Transaction::Error] A monetary transaction failed.
142
+ # @example
143
+ # account = Recurly::Account.find 'schrader'
144
+ # subscription = account.subscriptions.create attributes
145
+ # @see Resource.create
146
+ def create attributes = {}
147
+ new(attributes) { |record| record.save }
148
+ end
149
+
150
+ # Instantiates and saves a record in the scope of the pager.
151
+ #
152
+ # @return [Resource] The saved record.
153
+ # @raise [Invalid] The record is invalid.
154
+ # @raise [Transaction::Error] A monetary transaction failed.
155
+ # @example
156
+ # account = Recurly::Account.find 'schrader'
157
+ # subscription = account.subscriptions.create! attributes
158
+ # @see Resource.create!
159
+ def create! attributes = {}
160
+ new(attributes) { |record| record.save! }
161
+ end
162
+
163
+ # @return [true, false]
164
+ # @see Object#respond_to?
165
+ def respond_to? method_name, include_private = false
166
+ super || [].respond_to?(method_name, include_private)
167
+ end
168
+
169
+ private
170
+
171
+ def load_from uri, params
172
+ options = {}
173
+ options[:head] = { 'If-None-Match' => etag } if etag
174
+ response = API.get uri, params, options
175
+
176
+ @etag = response['ETag']
177
+ @count = response['X-Records'].to_i
178
+ @links = {}
179
+ if links = response['Link']
180
+ links.scan(/<([^>]+)>; rel="([^"]+)"/).each do |link|
181
+ @links[link.last] = link.first.freeze
182
+ end
183
+ end
184
+ @links.freeze
185
+
186
+ @collection = []
187
+ document = XML.new response.body
188
+ document.each_element(resource_class.member_name) do |el|
189
+ record = resource_class.from_xml el
190
+ record.attributes[@parent.class.member_name] = @parent if @parent
191
+ @collection << record
192
+ end
193
+ @collection.freeze
194
+ rescue API::NotModified
195
+ defined? @collection and @collection or raise
196
+ end
197
+
198
+ def method_missing name, *args, &block
199
+ scope = resource_class.scopes[name] and return paginate scope
200
+
201
+ if [].respond_to? name
202
+ load! unless defined? @collection
203
+ return @collection.send name, *args, &block
204
+ end
205
+
206
+ super
207
+ end
208
+ end
209
+ end
210
+ end
@@ -1,87 +1,110 @@
1
1
  module Recurly
2
- class Subscription < AccountBase
3
- self.element_name = "subscription"
2
+ class Subscription < Resource
3
+ autoload :AddOns, 'recurly/subscription/add_ons'
4
4
 
5
- def self.known_attributes
6
- [
7
- "plan_code",
8
- "coupon_code",
9
- "unit_amount_in_cents",
10
- "quantity",
11
- "trial_ends_at"
12
- ]
13
- end
5
+ # @macro [attach] scope
6
+ # @scope class
7
+ # @return [Pager<Subscription>] A pager that yields +$1+ subscriptions.
8
+ scope :active, :state => :active
9
+ scope :in_trial, :state => :in_trial
10
+ scope :non_renewing, :state => :non_renewing
14
11
 
15
- # initialize associations
16
- def initialize(attributes = {}, persisted = false)
17
- attributes = attributes.with_indifferent_access
18
- attributes[:account] ||= {}
19
- attributes[:addons] ||= []
20
- super
21
- end
12
+ # @return [Account]
13
+ belongs_to :account
14
+ # @return [Plan]
15
+ belongs_to :plan
22
16
 
23
- # API inconsistency workaround: Pull plan_code into subscription to conform to known_attributes
24
- def load(attributes, remove_root = false)
25
- attributes = attributes.with_indifferent_access
26
- attributes[:plan_code] ||= attributes[:plan][:plan_code] if attributes.include?(:plan)
27
- super
28
- end
29
-
30
- def self.refund(account_code, refund_type = :partial)
31
- raise "Refund type must be :full, :partial, or :none." unless [:full, :partial, :none].include?(refund_type)
32
- Subscription.delete(nil, {:account_code => account_code, :refund => refund_type})
33
- end
17
+ define_attribute_methods %w(
18
+ uuid
19
+ state
20
+ unit_amount_in_cents
21
+ currency
22
+ quantity
23
+ activated_at
24
+ canceled_at
25
+ expires_at
26
+ current_period_started_at
27
+ current_period_ends_at
28
+ trial_started_at
29
+ trial_ends_at
30
+ pending_subscription
31
+ subscription_add_ons
32
+ )
33
+ alias to_param uuid
34
34
 
35
- # Terminates the subscription immediately and processes a full or partial refund
36
- def refund(refund_type)
37
- self.class.refund(self.subscription_account_code, refund_type)
35
+ # @return [Subscription] A new subscription.
36
+ def initialize attributes = {}
37
+ super({ :currency => Recurly.default_currency }.merge attributes)
38
38
  end
39
39
 
40
- def self.cancel(account_code)
41
- Subscription.delete(account_code)
40
+ # Assign a Plan resource (rather than a plan code).
41
+ #
42
+ # @param plan [Plan]
43
+ def plan= plan
44
+ self[:plan_code] = plan.plan_code if plan.respond_to? :plan_code
45
+ attributes[:plan] = plan
42
46
  end
43
47
 
44
- # Stops the subscription from renewing. The subscription remains valid until the end of
45
- # the current term (current_period_ends_at).
46
- def cancel(account_code = nil)
47
- unless account_code.nil?
48
- ActiveSupport::Deprecation.warn('Calling Recurly::Subscription#cancel with an account_code has been deprecated. Use the static method Recurly::Subscription.cancel(account_code) instead', caller)
49
- end
50
- self.class.cancel(account_code || self.subscription_account_code)
48
+ # @return [AddOns]
49
+ def subscription_add_ons
50
+ AddOns.new self, super
51
51
  end
52
+ alias add_ons subscription_add_ons
52
53
 
53
- def self.reactivate(account_code, options = {})
54
- path = "/accounts/#{CGI::escape(account_code.to_s)}/subscription/reactivate"
55
- connection.post(path, "", headers)
56
- rescue ActiveResource::Redirection => e
57
- return true
54
+ # Assign an array of subscription add-ons.
55
+ def subscription_add_ons= subscription_add_ons
56
+ super AddOns.new self, subscription_add_ons
58
57
  end
58
+ alias add_ons= subscription_add_ons=
59
59
 
60
- def reactivate
61
- self.class.reactivate(self.subscription_account_code)
60
+ # Cancel a subscription so that it will not renew.
61
+ #
62
+ # @return [true, false] +true+ when successful, +false+ when unable to
63
+ # (e.g., the subscription is not active).
64
+ # @example
65
+ # account = Account.find account_code
66
+ # subscription = account.subscriptions.first
67
+ # subscription.cancel # => true
68
+ def cancel
69
+ return false unless self[:cancel]
70
+ reload self[:cancel].call
71
+ true
62
72
  end
63
73
 
64
- # Valid timeframe: :now or :renewal
65
- # Valid options: plan_code, quantity, unit_amount
66
- def change(timeframe, options = {})
67
- raise "Timeframe must be :now or :renewal." unless ['now','renewal'].include?(timeframe)
68
- options[:timeframe] = timeframe
69
- path = "/accounts/#{CGI::escape(self.subscription_account_code.to_s)}/subscription.xml"
70
- connection.put(path,
71
- self.class.format.encode(options, :root => :subscription),
72
- self.class.headers)
73
- rescue ActiveResource::ResourceInvalid => e
74
- self.load_errors e.response.body
74
+ # An array of acceptable refund types.
75
+ REFUND_TYPES = ['none', 'full', 'partial'].freeze
76
+
77
+ # Immediately terminate a subscription (with optional refund).
78
+ #
79
+ # @return [true, false] +true+ when successful, +false+ when unable to
80
+ # (e.g., the subscription is not active).
81
+ # @param refund_type [:none, :full, :partial] <tt>:none</tt> terminates the
82
+ # subscription with no refund (the default), <tt>:full</tt> refunds the
83
+ # subscription in full, and <tt>:partial</tt> refunds the subscription in
84
+ # part.
85
+ # @raise [ArgumentError] Invalid +refund_type+.
86
+ # @example
87
+ # account = Account.find account_code
88
+ # subscription = account.subscriptions.first
89
+ # subscription.terminate(:partial) # => true
90
+ def terminate refund_type = :none
91
+ return false unless self[:terminate]
92
+ unless REFUND_TYPES.include? refund_type.to_s
93
+ raise ArgumentError, "refund must be one of: #{REFUND_TYPES.join ', '}"
94
+ end
95
+ reload self[:terminate].call(:params => { :refund => refund_type })
96
+ true
75
97
  end
76
98
 
77
- def subscription_account_code
78
- acct_code = self.account_code if defined?(self.account_code) and !self.account_code.nil? and !self.account_code.blank?
79
- acct_code ||= account.account_code if defined?(account) and !account.nil?
80
- acct_code ||= self.primary_key if defined?(self.primary_key)
81
- acct_code ||= self.id if defined?(self.id)
82
- raise 'Missing Account Code' if acct_code.blank?
83
- acct_code
99
+ # Reactivate a subscription.
100
+ #
101
+ # @return [true, false] +true+ when successful, +false+ when unable to
102
+ # (e.g., the subscription is already active), and may raise an exception
103
+ # if the reactivation fails.
104
+ def reactivate
105
+ return false unless self[:reactivate]
106
+ reload self[:reactivate].call
107
+ true
84
108
  end
85
109
  end
86
110
  end
87
-
@@ -0,0 +1,73 @@
1
+ module Recurly
2
+ class Subscription < Resource
3
+ class AddOns
4
+ instance_methods.each do |method|
5
+ undef_method method if method !~ /^__|^(object_id|respond_to\?|send)$/
6
+ end
7
+
8
+ # @param subscription [Subscription]
9
+ # @param add_ons [Array, nil]
10
+ def initialize subscription, add_ons = []
11
+ @subscription, @add_ons = subscription, []
12
+ add_ons and add_ons.each { |a| self << a }
13
+ end
14
+
15
+ # @return [self]
16
+ # @param add_on [AddOn, String, Symbol, Hash] A {Plan} add-on,
17
+ # +add_on_code+, or hash with optional <tt>:quantity</tt> and
18
+ # <tt>:unit_amount_in_cents</tt> keys.
19
+ # @example
20
+ # pp subscription.add_ons << '1YEARWAR' << '1YEARWAR' << :BONUS
21
+ # [
22
+ # {:add_on_code => "1YEARWAR", :quantity => 2},
23
+ # {:add_on_code => "BONUS"}
24
+ # ]
25
+ def << add_on
26
+ case add_on
27
+ when AddOn then add_on = { :add_on_code => add_on.add_on_code }
28
+ when String, Symbol then add_on = { :add_on_code => add_on.to_s }
29
+ end
30
+
31
+ exist = @add_ons.find { |a| a[:add_on_code] == add_on[:add_on_code] }
32
+ if exist
33
+ exist[:quantity] ||= 1 and exist[:quantity] += 1
34
+
35
+ if add_on[:unit_amount_in_cents]
36
+ exist[:unit_amount_in_cents] = add_on[:unit_amount_in_cents]
37
+ end
38
+ else
39
+ @add_ons << add_on
40
+ end
41
+
42
+ @subscription[:subscription_add_ons] = to_a and self
43
+ end
44
+
45
+ def to_a
46
+ @add_ons.dup
47
+ end
48
+
49
+ def to_xml options = {}
50
+ builder = options[:builder] || XML.new('<subscription_add_ons/>')
51
+ @add_ons.each do |add_on|
52
+ node = builder.add_element 'subscription_add_on'
53
+ add_on.each_pair { |k, v| node.add_element k.to_s, v }
54
+ end
55
+ builder.to_s
56
+ end
57
+
58
+ def respond_to? method_name, include_private = false
59
+ super || @add_ons.respond_to?(method_name, include_private)
60
+ end
61
+
62
+ private
63
+
64
+ def method_missing name, *args, &block
65
+ if @add_ons.respond_to? name
66
+ return @add_ons.send name, *args, &block
67
+ end
68
+
69
+ super
70
+ end
71
+ end
72
+ end
73
+ end