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,925 @@
1
+ require 'date'
2
+
3
+ module Recurly
4
+ # The base class for all Recurly resources (e.g. {Account}, {Subscription},
5
+ # {Transaction}).
6
+ #
7
+ # Resources behave much like
8
+ # {ActiveModel}[http://rubydoc.info/gems/activemodel] classes, especially
9
+ # like {ActiveRecord}[http://rubydoc.info/gems/activerecord].
10
+ #
11
+ # == Life Cycle
12
+ #
13
+ # To take you through the typical life cycle of a resource, we'll use
14
+ # {Recurly::Account} as an example.
15
+ #
16
+ # === Creating a Record
17
+ #
18
+ # You can instantiate a record before attempting to save it.
19
+ #
20
+ # account = Recurly::Account.new :first_name => 'Walter'
21
+ #
22
+ # Once instantiated, you can assign and reassign any attribute.
23
+ #
24
+ # account.first_name = 'Walt'
25
+ # account.last_name = 'White'
26
+ #
27
+ # When you're ready to save, do so.
28
+ #
29
+ # account.save # => false
30
+ #
31
+ # If save returns +false+, validation likely failed. You can check the record
32
+ # for errors.
33
+ #
34
+ # account.errors # => {"account_code"=>["can't be blank"]}
35
+ #
36
+ # Once the errors are fixed, you can try again.
37
+ #
38
+ # account.account_code = 'heisenberg'
39
+ # account.save # => true
40
+ #
41
+ # The object will be updated with any information provided by the server
42
+ # (including any UUIDs set).
43
+ #
44
+ # account.created_at # => 2011-04-30 07:13:35 -0700
45
+ #
46
+ # You can also create accounts in one fell swoop.
47
+ #
48
+ # Recurly::Account.create(
49
+ # :first_name => 'Jesse'
50
+ # :last_name => 'Pinkman'
51
+ # :account_code => 'capn_cook'
52
+ # )
53
+ # # => #<Recurly::Account account_code: "capn_cook" ...>
54
+ #
55
+ # You can use alternative "bang" methods for exception control. If the record
56
+ # fails to save, a Recurly::Resource::Invalid exception will be raised.
57
+ #
58
+ # begin
59
+ # account = Recurly::Account.new :first_name => 'Junior'
60
+ # account.save!
61
+ # rescue Recurly::Resource::Invalid
62
+ # p account.errors
63
+ # end
64
+ #
65
+ # You can access the invalid record from the exception itself (if, for
66
+ # example, you use the <tt>create!</tt> method).
67
+ #
68
+ # begin
69
+ # Recurly::Account.create! :first_name => 'Skylar', :last_name => 'White'
70
+ # rescue Recurly::Resource::Invalid => e
71
+ # p e.record.errors
72
+ # end
73
+ #
74
+ # === Fetching a Record
75
+ #
76
+ # Records are fetched by their unique identifiers.
77
+ #
78
+ # account = Recurly::Account.find 'better_call_saul'
79
+ # # => #<Recurly::Account account_code: "better_call_saul" ...>
80
+ #
81
+ # If the record doesn't exist, a Recurly::Resource::NotFound exception will
82
+ # be raised.
83
+ #
84
+ # === Updating a Record
85
+ #
86
+ # Once fetched, a record can be updated with a hash of attributes.
87
+ #
88
+ # account.update_attributes :first_name => 'Saul', :last_name => 'Goodman'
89
+ # # => true
90
+ #
91
+ # (A bang method, update_attributes!, will raise Recurly::Resource::Invalid.)
92
+ #
93
+ # You can also update a record by setting attributes and calling save.
94
+ #
95
+ # account.last_name = 'McGill'
96
+ # account.save # Alternatively, call save!
97
+ #
98
+ # === Deleting a Record
99
+ #
100
+ # To delete (deactivate, close, etc.) a fetched record, merely call destroy
101
+ # on it.
102
+ #
103
+ # account.destroy # => true
104
+ #
105
+ # === Fetching a List of Records
106
+ #
107
+ # If you want to iterate over a list of accounts, you can use a Pager.
108
+ #
109
+ # pager = Account.paginate :per_page => 50
110
+ #
111
+ # If you want to iterate over _every_ record, a convenience method will
112
+ # automatically paginate:
113
+ #
114
+ # Account.find_each { |account| p account }
115
+ class Resource
116
+ autoload :Pager, 'recurly/resource/pager'
117
+
118
+ # Raised when a record cannot be found.
119
+ #
120
+ # @example
121
+ # begin
122
+ # Recurly::Account.find 'tortuga'
123
+ # rescue Recurly::Resource::NotFound => e
124
+ # e.message # => "Can't find Account with account_code = tortuga"
125
+ # end
126
+ class NotFound < API::NotFound
127
+ def initialize message
128
+ set_message message
129
+ end
130
+ end
131
+
132
+ # Raised when a record is invalid.
133
+ #
134
+ # @example
135
+ # begin
136
+ # Recurly::Account.create! :first_name => "Flynn"
137
+ # rescue Recurly::Resource::Invalid => e
138
+ # e.record.errors # => errors: {"account_code"=>["can't be blank"]}>
139
+ # end
140
+ class Invalid < API::UnprocessableEntity
141
+ # @return [Resource, nil] The invalid record.
142
+ attr_reader :record
143
+
144
+ def initialize record_or_message
145
+ set_message case record_or_message
146
+ when Resource
147
+ @record = record_or_message
148
+ record_or_message.errors.map { |k, v| "#{k} #{v * ', '}" }.join '; '
149
+ else
150
+ record_or_message
151
+ end
152
+ end
153
+ end
154
+
155
+ class << self
156
+ # @return [String] The demodulized name of the resource class.
157
+ # @example
158
+ # Recurly::Account.name # => "Account"
159
+ def resource_name
160
+ Helper.demodulize name
161
+ end
162
+
163
+ # @return [String] The underscored, pluralized name of the resource
164
+ # class.
165
+ # @example
166
+ # Recurly::Account.collection_name # => "accounts"
167
+ def collection_name
168
+ Helper.pluralize Helper.underscore(resource_name)
169
+ end
170
+ alias collection_path collection_name
171
+
172
+ # @return [String] The underscored name of the resource class.
173
+ # @example
174
+ # Recurly::Account.member_name # => "account"
175
+ def member_name
176
+ Helper.underscore resource_name
177
+ end
178
+
179
+ # @return [String] The relative path to a resource's identifier from the
180
+ # API's base URI.
181
+ # @param uuid [String, nil]
182
+ # @example
183
+ # Recurly::Account.member_path "code" # => "accounts/code"
184
+ # Recurly::Account.member_path nil # => "accounts"
185
+ def member_path uuid
186
+ [collection_path, uuid].compact.join '/'
187
+ end
188
+
189
+ # @return [Array] Per attribute, defines readers, writers, boolean and
190
+ # change-tracking methods.
191
+ # @param attribute_names [Array] An array of attribute names.
192
+ # @example
193
+ # class Account < Resource
194
+ # define_attribute_methods [:name]
195
+ # end
196
+ #
197
+ # a = Account.new
198
+ # a.name? # => false
199
+ # a.name # => nil
200
+ # a.name = "Stephen"
201
+ # a.name? # => true
202
+ # a.name # => "Stephen"
203
+ # a.name_changed? # => true
204
+ # a.name_was # => nil
205
+ # a.name_change # => [nil, "Stephen"]
206
+ def define_attribute_methods attribute_names
207
+ @attribute_names = attribute_names.map! { |m| m.to_s }.sort!.freeze
208
+ remove_const :AttributeMethods if const_defined? :AttributeMethods
209
+ include const_set :AttributeMethods, Module.new {
210
+ attribute_names.each do |name|
211
+ define_method(name) { self[name] } # Get.
212
+ define_method("#{name}=") { |value| self[name] = value } # Set.
213
+ define_method("#{name}?") { !!self[name] } # Present.
214
+ define_method("#{name}_change") { changes[name] } # Dirt...
215
+ define_method("#{name}_changed?") { changed_attributes.key? name }
216
+ define_method("#{name}_was") { changed_attributes[name] }
217
+ define_method("#{name}_previously_changed?") {
218
+ previous_changes.key? name
219
+ }
220
+ define_method("#{name}_previously_was") {
221
+ previous_changes[name].first if previous_changes.key? name
222
+ }
223
+ end
224
+ }
225
+ end
226
+
227
+ # @return [Array, nil] The list of attribute names defined for the
228
+ # resource class.
229
+ attr_reader :attribute_names
230
+
231
+ # @return [Pager] A pager with an iterable collection of records
232
+ # @param options [Hash] A hash of pagination options
233
+ # @option options [Integer] :per_page The number of records returned per
234
+ # page
235
+ # @option options [DateTime, Time, Integer] :cursor A timestamp that the
236
+ # pager will skim back to and return records created before it
237
+ # @option options [String] :etag When set, will raise
238
+ # {Recurly::API::NotModified} if the pager's loaded page content has
239
+ # not changed
240
+ # @example Fetch 50 records and iterate over them
241
+ # Recurly::Account.paginate(:per_page => 50).each { |a| p a }
242
+ # @example Fetch records before January 1, 2011
243
+ # Recurly::Account.paginate(:cursor => Time.new(2011, 1, 1))
244
+ def paginate options = {}
245
+ Pager.new self, options
246
+ end
247
+ alias scoped paginate
248
+ alias where paginate
249
+
250
+ def all options = {}
251
+ paginate(options).to_a
252
+ end
253
+
254
+ # @return [Hash] Defined scopes per resource.
255
+ def scopes
256
+ @scopes ||= Recurly::Helper.hash_with_indifferent_read_access
257
+ end
258
+
259
+ # Defines a new resource scope.
260
+ #
261
+ # @return [Proc]
262
+ # @param [Symbol] name the scope name
263
+ # @param [Hash] params the scope params
264
+ def scope name, params = {}
265
+ scopes[name = name.to_s] = params
266
+ extend const_set :Scopes, Module.new unless const_defined? :Scopes
267
+ self::Scopes.send(:define_method, name) { paginate scopes[name] }
268
+ end
269
+
270
+ # Iterates through every record by automatically paging.
271
+ #
272
+ # @return [nil]
273
+ # @param [Integer] per_page The number of records returned per request.
274
+ # @yield [record]
275
+ # @see Pager#find_each
276
+ # @example
277
+ # Recurly::Account.find_each { |a| p a }
278
+ def find_each per_page = 50
279
+ paginate(:per_page => per_page).find_each(&Proc.new)
280
+ end
281
+
282
+ # @return [Integer] The total record count of the resource in question.
283
+ # @see Pager#count
284
+ # @example
285
+ # Recurly::Account.count # => 42
286
+ def count
287
+ paginate.count
288
+ end
289
+
290
+ # @api internal
291
+ # @return [Resource, nil]
292
+ def first
293
+ paginate(:per_page => 1).first
294
+ end
295
+
296
+ # @return [Resource] A record matching the designated unique identifier.
297
+ # @param [String] uuid The unique identifier of the resource to be
298
+ # retrieved.
299
+ # @param [Hash] options A hash of options.
300
+ # @option options [String] :etag When set, will raise {API::NotModified}
301
+ # if the record content has not changed.
302
+ # @raise [Error] If the resource has no identifier (and thus cannot be
303
+ # retrieved).
304
+ # @raise [NotFound] If no resource can be found for the supplied
305
+ # identifier (or the supplied identifier is +nil+).
306
+ # @raise [API::NotModified] If the <tt>:etag</tt> option is set and
307
+ # matches the server's.
308
+ # @example
309
+ # Recurly::Account.find "heisenberg"
310
+ # # => #<Recurly::Account account_code: "heisenberg", ...>
311
+ def find uuid, options = {}
312
+ if uuid.nil?
313
+ # Should we raise an ArgumentError, instead?
314
+ raise NotFound, "can't find a record with nil identifier"
315
+ end
316
+
317
+ request_options = {}
318
+ if etag = options[:etag]
319
+ request_options[:head] = { 'If-None-Match' => etag }
320
+ end
321
+
322
+ uri = uuid =~ /^http/ ? uuid : member_path(uuid)
323
+ begin
324
+ from_response API.get(uri, {}, request_options)
325
+ rescue API::NotFound => e
326
+ raise NotFound, e.description
327
+ end
328
+ end
329
+
330
+ # Instantiates and attempts to save a record.
331
+ #
332
+ # @return [Resource] The record.
333
+ # @raise [Transaction::Error] A monetary transaction failed.
334
+ # @see create!
335
+ def create attributes = {}
336
+ new(attributes) { |record| record.save }
337
+ end
338
+
339
+ # Instantiates and attempts to save a record.
340
+ #
341
+ # @return [Resource] The saved record.
342
+ # @raise [Invalid] The record is invalid.
343
+ # @raise [Transaction::Error] A monetary transaction failed.
344
+ # @see create
345
+ def create! attributes = {}
346
+ new(attributes) { |record| record.save! }
347
+ end
348
+
349
+ # Instantiates a record from an HTTP response, setting the record's
350
+ # response attribute in the process.
351
+ #
352
+ # @return [Resource]
353
+ # @param response [Net::HTTPResponse]
354
+ def from_response response
355
+ record = from_xml response.body
356
+ record.instance_eval { @etag, @response = response['ETag'], response }
357
+ record
358
+ end
359
+
360
+ # Instantiates a record from an XML blob: either a String or XML element.
361
+ #
362
+ # Assuming the record is from an API response, the record is flagged as
363
+ # persisted.
364
+ #
365
+ # @return [Resource]
366
+ # @param xml [String, REXML::Element, Nokogiri::XML::Node]
367
+ # @see from_response
368
+ def from_xml xml
369
+ xml = XML.new xml
370
+ if xml.name == member_name
371
+ record = new
372
+ elsif Recurly.const_defined?(class_name = Helper.classify(xml.name))
373
+ record = Recurly.const_get(class_name).new
374
+ elsif root = xml.root and root.elements.empty?
375
+ return XML.cast root
376
+ else
377
+ record = {}
378
+ end
379
+
380
+ xml.root.attributes.each do |name, value|
381
+ record.instance_variable_set "@#{name}", value.to_s
382
+ end
383
+
384
+ xml.each_element do |el|
385
+ if el.name == 'a'
386
+ name, uri = el.attribute('name').value, el.attribute('href').value
387
+ record[name] = case el.attribute('method').to_s
388
+ when 'get', '' then proc { |*opts| API.get uri, {}, *opts }
389
+ when 'post' then proc { |*opts| API.post uri, nil, *opts }
390
+ when 'put' then proc { |*opts| API.put uri, nil, *opts }
391
+ when 'delete' then proc { |*opts| API.delete uri, *opts }
392
+ end
393
+ next
394
+ end
395
+
396
+ if el.children.empty? && href = el.attribute('href')
397
+ resource_class = Recurly.const_get(
398
+ Helper.classify(el.attribute('type') || el.name)
399
+ )
400
+ record[el.name] = case el.name
401
+ when *associations[:has_many]
402
+ Pager.new resource_class, :uri => href.value, :parent => record
403
+ when *(associations[:has_one] + associations[:belongs_to])
404
+ lambda {
405
+ begin
406
+ relation = resource_class.from_response API.get(href.value)
407
+ relation.attributes[member_name] = record
408
+ relation
409
+ rescue Recurly::API::NotFound
410
+ end
411
+ }
412
+ end
413
+ else
414
+ record[el.name] = XML.cast el
415
+ end
416
+ end
417
+
418
+ record.persist! if record.respond_to? :persist!
419
+ record
420
+ end
421
+
422
+ # @return [Hash] A list of association names for the current class.
423
+ def associations
424
+ @associations ||= begin
425
+ unless const_defined? :Associations
426
+ include const_set :Associations, Module.new
427
+ end
428
+
429
+ { :has_many => [], :has_one => [], :belongs_to => [] }
430
+ end
431
+ end
432
+
433
+ # Establishes a has_many association.
434
+ #
435
+ # @return [Proc, nil]
436
+ # @param collection_name [Symbol] Association name.
437
+ # @param options [Hash] A hash of association options.
438
+ # @option options [true, false] :readonly Don't define a setter.
439
+ def has_many collection_name, options = {}
440
+ associations[:has_many] << collection_name.to_s
441
+ self::Associations.module_eval {
442
+ define_method(collection_name) {
443
+ self[collection_name] ||= []
444
+ }
445
+ if options.key?(:readonly) && options[:readonly] == false
446
+ define_method("#{collection_name}=") { |collection|
447
+ self[collection_name] = collection
448
+ }
449
+ end
450
+ }
451
+ end
452
+
453
+ # Establishes a has_one association.
454
+ #
455
+ # @return [Proc, nil]
456
+ # @param member_name [Symbol] Association name.
457
+ # @param options [Hash] A hash of association options.
458
+ # @option options [true, false] :readonly Don't define a setter.
459
+ def has_one member_name, options = {}
460
+ associations[:has_one] << member_name.to_s
461
+ self::Associations.module_eval {
462
+ define_method(member_name) { self[member_name] }
463
+ if options.key?(:readonly) && options[:readonly] == false
464
+ associated = Recurly.const_get Helper.classify(member_name)
465
+ define_method("#{member_name}=") { |member|
466
+ associated_uri = "#{path}/#{member_name}"
467
+ self[member_name] = case member
468
+ when Hash
469
+ associated.send :new, member.merge(:uri => associated_uri)
470
+ when associated_class
471
+ member.uri = associated_uri and member
472
+ else
473
+ raise ArgumentError, "expected #{associated_class}"
474
+ end
475
+ }
476
+ define_method("build_#{member_name}") { |*args|
477
+ attributes = args.shift || {}
478
+ self[member_name] = associated.send(
479
+ :new, attributes.merge(:uri => "#{path}/#{member_name}")
480
+ )
481
+ }
482
+ define_method("create_#{member_name}") { |*args|
483
+ send("build_#{member_name}", *args).tap { |child| child.save }
484
+ }
485
+ end
486
+ }
487
+ end
488
+
489
+ # Establishes a belongs_to association.
490
+ #
491
+ # @return [Proc]
492
+ def belongs_to parent_name, options = {}
493
+ associations[:belongs_to] << parent_name.to_s
494
+ self::Associations.module_eval {
495
+ define_method(parent_name) { self[parent_name] }
496
+ if options.key?(:readonly) && options[:readonly] == false
497
+ define_method("#{parent_name}=") { |parent|
498
+ self[parent_name] = parent
499
+ }
500
+ end
501
+ }
502
+ end
503
+
504
+ # @return [:has_many, :has_one, :belongs_to, nil] An association type.
505
+ def reflect_on_association name
506
+ a = associations.find { |k, v| v.include? name.to_s } and a.first
507
+ end
508
+
509
+ def embedded!
510
+ private_class_method(*%w(
511
+ new create create! paginate find_each scoped where all
512
+ ))
513
+ private :initialize
514
+ end
515
+ end
516
+
517
+ # @return [Hash] The raw hash of record attributes.
518
+ attr_reader :attributes
519
+
520
+ # @return [Net::HTTPResponse, nil] The most recent response object for the
521
+ # record (updated during {#save} and {#destroy}).
522
+ attr_reader :response
523
+
524
+ # @return [String, nil] An ETag for the current record.
525
+ attr_reader :etag
526
+
527
+ # @return [String, nil] A writer to override the URI the record saves to.
528
+ attr_writer :uri
529
+
530
+ # @return [Resource] A new resource instance.
531
+ # @param attributes [Hash] A hash of attributes.
532
+ def initialize attributes = {}
533
+ if instance_of? Resource
534
+ raise Error,
535
+ "#{self.class} is an abstract class and cannot be instantiated"
536
+ end
537
+
538
+ @attributes, @new_record, @destroyed, @uri, @href = {}, true, false
539
+ self.attributes = attributes
540
+ yield self if block_given?
541
+ end
542
+
543
+ def to_param
544
+ self[self.class.param_name]
545
+ end
546
+
547
+ # @return [self] Reloads the record from the server.
548
+ def reload response = nil
549
+ if response
550
+ return if response.body.length.zero?
551
+ fresh = self.class.from_response response
552
+ else
553
+ fresh = self.class.find(
554
+ @href || to_param, :etag => (etag unless changed?)
555
+ )
556
+ end
557
+ fresh and copy_from fresh
558
+ persist! true
559
+ self
560
+ rescue API::NotModified
561
+ self
562
+ end
563
+
564
+ # @return [Hash] Hash of changed attributes.
565
+ # @see #changes
566
+ def changed_attributes
567
+ @changed_attributes ||= {}
568
+ end
569
+
570
+ # @return [Array] A list of changed attribute keys.
571
+ def changed
572
+ changed_attributes.keys
573
+ end
574
+
575
+ # Do any attributes have unsaved changes?
576
+ # @return [true, false]
577
+ def changed?
578
+ !changed_attributes.empty?
579
+ end
580
+
581
+ # @return [Hash] Map of changed attributes to original value and new value.
582
+ def changes
583
+ changed_attributes.inject({}) { |changes, (key, original_value)|
584
+ changes[key] = [original_value, self[key]] and changes
585
+ }
586
+ end
587
+
588
+ # @return [Hash] Previously-changed attributes.
589
+ # @see #changes
590
+ def previous_changes
591
+ @previous_changes ||= {}
592
+ end
593
+
594
+ # Is the record new (i.e., not saved on Recurly's servers)?
595
+ #
596
+ # @return [true, false]
597
+ # @see #persisted?
598
+ # @see #destroyed?
599
+ def new_record?
600
+ @new_record
601
+ end
602
+
603
+ # Has the record been destroyed? (Set +true+ after a successful destroy.)
604
+ # @return [true, false]
605
+ # @see #new_record?
606
+ # @see #persisted?
607
+ def destroyed?
608
+ @destroyed
609
+ end
610
+
611
+ # Has the record persisted (i.e., saved on Recurly's servers)?
612
+ #
613
+ # @return [true, false]
614
+ # @see #new_record?
615
+ # @see #destroyed?
616
+ def persisted?
617
+ !(new_record? || destroyed?)
618
+ end
619
+
620
+ # The value of a specified attribute, lazily fetching any defined
621
+ # association.
622
+ #
623
+ # @param key [Symbol, String] The name of the attribute to be fetched.
624
+ # @example
625
+ # account.read_attribute :first_name # => "Ted"
626
+ # account[:last_name] # => "Beneke"
627
+ # @see #write_attribute
628
+ def read_attribute key
629
+ value = attributes[key = key.to_s]
630
+ if value.respond_to?(:call) && self.class.reflect_on_association(key)
631
+ value = attributes[key] = value.call
632
+ end
633
+ value
634
+ end
635
+ alias [] read_attribute
636
+
637
+ # Sets the value of a specified attribute.
638
+ #
639
+ # @param key [Symbol, String] The name of the attribute to be set.
640
+ # @param value [Object] The value the attribute will be set to.
641
+ # @example
642
+ # account.write_attribute :first_name, 'Gus'
643
+ # account[:company_name] = 'Los Pollos Hermanos'
644
+ # @see #read_attribute
645
+ def write_attribute key, value
646
+ if changed_attributes.key?(key = key.to_s)
647
+ changed_attributes.delete key if changed_attributes[key] == value
648
+ elsif self[key] != value
649
+ changed_attributes[key] = self[key]
650
+ end
651
+
652
+ if self.class.associations.values.flatten.include? key
653
+ value = fetch_association key, value
654
+ # FIXME: More explicit; less magic.
655
+ elsif key.end_with?('_in_cents') && !respond_to?(:currency)
656
+ value = Money.new value unless value.is_a? Money
657
+ end
658
+
659
+ attributes[key] = value
660
+ end
661
+ alias []= write_attribute
662
+
663
+ # Apply a given hash of attributes to a record.
664
+ #
665
+ # @return [Hash]
666
+ # @param attributes [Hash] A hash of attributes.
667
+ def attributes= attributes = {}
668
+ attributes.each_pair { |k, v|
669
+ respond_to?(name = "#{k}=") and send(name, v) or self[k] = v
670
+ }
671
+ end
672
+
673
+ # Serializes the record to XML.
674
+ #
675
+ # @return [String] An XML string.
676
+ # @param options [Hash] A hash of XML options.
677
+ # @example
678
+ # Recurly::Account.new(:account_code => 'code').to_xml
679
+ # # => "<account><account_code>code</account_code></account>"
680
+ def to_xml options = {}
681
+ builder = options[:builder] || XML.new("<#{self.class.member_name}/>")
682
+ xml_keys.each { |key|
683
+ value = respond_to?(key) ? send(key) : self[key]
684
+ node = builder.add_element key
685
+
686
+ if value.respond_to? :to_xml
687
+ value.to_xml options.merge(:builder => node)
688
+ elsif value.respond_to? :each_pair
689
+ value.each_pair { |k, v| node.add_element k.to_s, v }
690
+ elsif value.respond_to? :each
691
+ value.each { |e| node.add_element Helper.singularize(key), e }
692
+ else
693
+ node.text = value
694
+ end
695
+ }
696
+ builder.to_s
697
+ end
698
+
699
+ # Attempts to save the record, returning the success of the request.
700
+ #
701
+ # @return [true, false]
702
+ # @raise [Transaction::Error] A monetary transaction failed.
703
+ # @example
704
+ # account = Recurly::Account.new
705
+ # account.save # => false
706
+ # account.account_code = 'account_code'
707
+ # account.save # => true
708
+ # @see #save!
709
+ def save
710
+ if new_record? || changed?
711
+ clear_errors
712
+ @response = API.send(
713
+ persisted? ? :put : :post, path, to_xml(:delta => true)
714
+ )
715
+ reload response
716
+ persist! true
717
+ end
718
+ true
719
+ rescue API::UnprocessableEntity => e
720
+ apply_errors e
721
+ Transaction::Error.validate! e, (self if is_a? Transaction)
722
+ false
723
+ end
724
+
725
+ # Attempts to save the record, returning +true+ if the record was saved and
726
+ # raising {Invalid} otherwise.
727
+ #
728
+ # @return [true]
729
+ # @raise [Invalid] The record was invalid.
730
+ # @raise [Transaction::Error] A monetary transaction failed.
731
+ # @example
732
+ # account = Recurly::Account.new
733
+ # account.save! # raises Recurly::Resource::Invalid
734
+ # account.account_code = 'account_code'
735
+ # account.save! # => true
736
+ # @see #save
737
+ def save!
738
+ save || raise(Invalid.new(self))
739
+ end
740
+
741
+ # @return [true, false, nil] The validity of the record: +true+ if the
742
+ # record was successfully saved (or persisted and unchanged), +false+ if
743
+ # the record was not successfully saved, or +nil+ for a record with an
744
+ # unknown state (i.e. (i.e. new records that haven't been saved and
745
+ # persisted records with changed attributes).
746
+ # @example
747
+ # account = Recurly::Account.new
748
+ # account.valid? # => nil
749
+ # account.save # => false
750
+ # account.valid? # => false
751
+ # account.account_code = 'account_code'
752
+ # account.save # => true
753
+ # account.valid? # => true
754
+ def valid?
755
+ return true if persisted? && changed_attributes.empty?
756
+ return if response.nil? || (errors.empty? && changed_attributes?)
757
+ errors.empty?
758
+ end
759
+
760
+ # Update a record with a given hash of attributes.
761
+ #
762
+ # @return [true, false] The success of the update.
763
+ # @param attributes [Hash] A hash of attributes.
764
+ # @raise [Transaction::Error] A monetary transaction failed.
765
+ # @example
766
+ # account = Account.find 'junior'
767
+ # account.update_attributes :account_code => 'flynn' # => true
768
+ # @see #update_attributes!
769
+ def update_attributes attributes = {}
770
+ self.attributes = attributes and save
771
+ end
772
+
773
+ # Update a record with a given hash of attributes.
774
+ #
775
+ # @return [true] The update was successful.
776
+ # @param attributes [Hash] A hash of attributes.
777
+ # @raise [Invalid] The record was invalid.
778
+ # @raise [Transaction::Error] A monetary transaction failed.
779
+ # @example
780
+ # account = Account.find 'gale_boetticher'
781
+ # account.update_attributes! :account_code => nil # Raises an exception.
782
+ # @see #update_attributes
783
+ def update_attributes! attributes = {}
784
+ self.attributes = attributes and save!
785
+ end
786
+
787
+ # @return [Hash] A hash with indifferent read access containing any
788
+ # validation errors where the key is the attribute name and the value is
789
+ # an array of error messages.
790
+ # @example
791
+ # account.errors # => {"account_code"=>["can't be blank"]}
792
+ # account.errors[:account_code] # => ["can't be blank"]
793
+ def errors
794
+ @errors ||= Recurly::Helper.hash_with_indifferent_read_access
795
+ end
796
+
797
+ # Marks a record as persisted, i.e. not a new or deleted record, resetting
798
+ # any tracked attribute changes in the process. (This is an internal method
799
+ # and should probably not be called unless you know what you're doing.)
800
+ #
801
+ # @api internal
802
+ # @return [true]
803
+ def persist! saved = false
804
+ @new_record, @uri = false
805
+ if changed?
806
+ @previous_changes = changes if saved
807
+ changed_attributes.clear
808
+ end
809
+ true
810
+ end
811
+
812
+ # @return [String, nil] The unique resource identifier (URI) of the record
813
+ # (if persisted).
814
+ # @example
815
+ # Recurly::Account.new(:account_code => "account_code").uri # => nil
816
+ # Recurly::Account.find("account_code").uri
817
+ # # => "https://api.recurly.com/v2/accounts/account_code"
818
+ def uri
819
+ @href ||= ((API.base_uri + path).to_s if persisted?)
820
+ end
821
+
822
+ # Attempts to destroy the record.
823
+ #
824
+ # @return [true, false] +true+ if successful, +false+ if unable to destroy
825
+ # (if the record does not persist on Recurly).
826
+ # @raise [NotFound] The record cannot be found.
827
+ # @example
828
+ # account = Recurly::Account.find account_code
829
+ # race_condition = Recurly::Account.find account_code
830
+ # account.destroy # => true
831
+ # account.destroy # => false (already destroyed)
832
+ # race_condition.destroy # raises Recurly::Resource::NotFound
833
+ def destroy
834
+ return false unless persisted?
835
+ @response = API.delete uri
836
+ @destroyed = true
837
+ rescue API::NotFound => e
838
+ raise NotFound, e.description
839
+ end
840
+
841
+ def == other
842
+ other.is_a?(self.class) && other.to_s == to_s
843
+ end
844
+
845
+ # @return [String]
846
+ def inspect attributes = self.class.attribute_names.to_a
847
+ string = "#<#{self.class}"
848
+ string << "##@type" if instance_variable_defined? :@type
849
+ attributes += %w(errors) if errors.any?
850
+ string << " %s" % attributes.map { |k|
851
+ "#{k}: #{self.send(k).inspect}"
852
+ }.join(', ')
853
+ string << '>'
854
+ end
855
+ alias to_s inspect
856
+
857
+ protected
858
+
859
+ def path
860
+ @href or @uri or if persisted?
861
+ self.class.member_path to_param
862
+ else
863
+ self.class.collection_path
864
+ end
865
+ end
866
+
867
+ def invalid! attribute_path, error
868
+ if attribute_path.length == 1
869
+ (errors[attribute_path[0]] ||= []) << error
870
+ else
871
+ child, k, v = attribute_path.shift.scan(/[^\[\]=]+/)
872
+ if c = k ? self[child].find { |d| d[k] == v } : self[child]
873
+ c.invalid! attribute_path, error
874
+ (e = errors[child] ||= []) << 'is invalid' and e.uniq!
875
+ end
876
+ end
877
+ end
878
+
879
+ def clear_errors
880
+ errors.clear
881
+ self.class.associations.each_value do |associations|
882
+ associations.each do |association|
883
+ next unless respond_to? "#{association}=" # Clear writable only.
884
+ [*self[association]].each do |associated|
885
+ associated.clear_errors if associated.respond_to? :clear_errors
886
+ end
887
+ end
888
+ end
889
+ end
890
+
891
+ def copy_from other
892
+ other.instance_variables.each do |ivar|
893
+ instance_variable_set ivar, other.instance_variable_get(ivar)
894
+ end
895
+ end
896
+
897
+ def apply_errors exception
898
+ @response = exception.response
899
+ document = XML.new exception.response.body
900
+ document.each_element 'error' do |el|
901
+ attribute_path = el.attribute('field').value.split '.'
902
+ invalid! attribute_path[1, attribute_path.length], el.text
903
+ end
904
+ end
905
+
906
+ private
907
+
908
+ def fetch_association name, value
909
+ case value
910
+ when Array
911
+ value.map { |each| fetch_association Helper.singularize(name), each }
912
+ when Hash
913
+ Recurly.const_get(Helper.classify(name)).send :new, value
914
+ when Proc, Resource, Resource::Pager, nil
915
+ value
916
+ else
917
+ raise "unexpected association #{name.inspect}=#{value.inspect}"
918
+ end
919
+ end
920
+
921
+ def xml_keys
922
+ changed_attributes.keys.sort
923
+ end
924
+ end
925
+ end