├── lib
├── ecurly.rb
├── recurly
│ ├── version.rb
│ ├── resource
│ │ ├── errors.rb
│ │ └── pager.rb
│ ├── add_on.rb
│ ├── all.rb
│ ├── plan.rb
│ ├── subscription_add_on.rb
│ ├── redemption.rb
│ ├── xml
│ │ ├── rexml.rb
│ │ └── nokogiri.rb
│ ├── helper.rb
│ ├── adjustment.rb
│ ├── billing_info.rb
│ ├── account.rb
│ ├── invoice.rb
│ ├── coupon.rb
│ ├── subscription
│ │ └── add_ons.rb
│ ├── xml.rb
│ ├── js.rb
│ ├── transaction.rb
│ ├── api.rb
│ ├── transaction
│ │ └── errors.rb
│ ├── api
│ │ ├── net_http_adapter.rb
│ │ └── errors.rb
│ ├── money.rb
│ ├── subscription.rb
│ └── resource.rb
├── rails
│ ├── recurly.rb
│ └── generators
│ │ └── recurly
│ │ └── config_generator.rb
└── recurly.rb
├── .yardopts
├── .gitignore
├── Rakefile
├── spec
├── fixtures
│ ├── invoices
│ │ ├── create-422.xml
│ │ └── create-201.xml
│ ├── accounts
│ │ ├── create-422.xml
│ │ ├── update-422.xml
│ │ ├── show-404.xml
│ │ ├── show-200.xml
│ │ ├── update-200.xml
│ │ ├── create-201.xml
│ │ └── index-200.xml
│ ├── adjustments
│ │ ├── show-404.xml
│ │ └── show-200.xml
│ ├── redemptions
│ │ └── create-201.xml
│ ├── coupons
│ │ └── show-200.xml
│ ├── billing_info
│ │ └── show-200.xml
│ ├── subscriptions
│ │ ├── show-200-inactive.xml
│ │ └── show-200.xml
│ ├── transactions
│ │ └── show-200.xml
│ └── transaction_error.xml
├── recurly
│ ├── api_spec.rb
│ ├── plan_spec.rb
│ ├── coupon_spec.rb
│ ├── money_spec.rb
│ ├── billing_info_spec.rb
│ ├── adjustment_spec.rb
│ ├── transaction_spec.rb
│ ├── xml_spec.rb
│ ├── js_spec.rb
│ ├── account_spec.rb
│ ├── resource
│ │ └── pager_spec.rb
│ ├── subscription_spec.rb
│ └── resource_spec.rb
├── environment.rb
├── recurly_spec.rb
└── spec_helper.rb
├── Gemfile
├── .travis.yml
├── doc
└── yard_extensions.rb
├── Gemfile.lock
├── recurly.gemspec
├── bin
└── recurly
└── README.markdown
/lib/ecurly.rb:
--------------------------------------------------------------------------------
1 | require 'recurly'
2 |
--------------------------------------------------------------------------------
/.yardopts:
--------------------------------------------------------------------------------
1 | --load ./doc/yard_extensions.rb
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Builds
2 | *.gem
3 |
4 | # Documentation
5 | .yardoc
6 | doc/*
7 | !doc/yard_extensions.rb
8 |
9 | # Bundler
10 | .bundle
11 | bin/*
12 | !bin/recurly
13 |
14 | TODO
15 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'rake/testtask'
2 |
3 | Rake::TestTask.new :spec do |t|
4 | t.libs << 'spec'
5 | t.pattern = 'spec/**/*_spec.rb'
6 | t.warning = true
7 | end
8 |
9 | task :default => :spec
10 |
--------------------------------------------------------------------------------
/spec/fixtures/invoices/create-422.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 422 Unprocessable Entity
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 | will_not_invoice
7 | No charges to invoice
8 |
9 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/create-422.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 422 Unprocessable Entity
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 | is not a valid email address
7 |
8 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/update-422.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 422 Unprocessable Entity
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 | is not a valid email address
7 |
8 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/show-404.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 404 Not Found
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 | not_found
7 | Couldn't find Account with account_code = not-found
8 |
9 |
--------------------------------------------------------------------------------
/spec/fixtures/adjustments/show-404.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 404 Not Found
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 | not_found
7 | Couldn't find Adjustment with uuid = abcdef1234567890
8 |
9 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 | gemspec
3 |
4 | group :development do
5 | gem 'nokogiri', '~> 1.5.0', :group => :test
6 | gem 'jruby-openssl', '~> 0.7.4', :platforms => :jruby # For WebMock.
7 |
8 | gem 'redcarpet', :platforms => :ruby
9 | gem 'yard'
10 | end
11 |
--------------------------------------------------------------------------------
/lib/recurly/version.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | module Version
3 | MAJOR = 2
4 | MINOR = 1
5 | PATCH = 9
6 | PRE = nil
7 |
8 | VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join('.').freeze
9 |
10 | class << self
11 | def inspect
12 | VERSION.dup
13 | end
14 | alias to_s inspect
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/spec/recurly/api_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe API do
4 | describe "HTTP errors" do
5 | it "must raise exceptions" do
6 | API::ERRORS.each_pair do |code, exception|
7 | stub_api_request(:any, 'endpoint') { "HTTP/1.1 #{code}\n" }
8 | proc { API.get 'endpoint' }.must_raise exception
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/rails/recurly.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Railtie < Rails::Railtie
3 | initializer :recurly_set_logger do
4 | Recurly.logger = Rails.logger
5 | end
6 |
7 | initializer :recurly_set_accept_language do
8 | ActionController::Base.prepend_before_filter do
9 | Recurly::API.accept_language = request.env['HTTP_ACCEPT_LANGUAGE']
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - 1.8.7
4 | - 1.9.2
5 | - 1.9.3
6 | - jruby-18mode
7 | - jruby-19mode
8 | - rbx-18mode
9 | - rbx-19mode
10 | env:
11 | - XML=rexml
12 | - XML=nokogiri
13 | matrix:
14 | allow_failures:
15 | - rvm: jruby-18mode
16 | env: XML=nokogiri
17 | - rvm: jruby-19mode
18 | env: XML=nokogiri
19 | notifications:
20 | email:
21 | recipients:
22 | - dev@recurly.com
23 | branches:
24 | only:
25 | - master
26 | - v2-stable
27 |
--------------------------------------------------------------------------------
/lib/recurly/resource/errors.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Resource
3 | class Errors < Hash
4 | def [] key
5 | super key.to_s
6 | end
7 |
8 | def []= key, value
9 | super key.to_s, value
10 | end
11 |
12 | def full_messages
13 | map { |attribute, messages|
14 | attribute_name = attribute.capitalize.gsub('_', ' ')
15 | messages.map { |message| "#{attribute_name} #{message}." }
16 | }.flatten
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/spec/recurly/plan_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Plan do
4 | let(:plan) {
5 | Plan.new(
6 | :plan_code => "gold",
7 | :name => "The Gold Plan",
8 | :unit_amount_in_cents => 79_00
9 | )
10 | }
11 |
12 | it 'must serialize' do
13 | plan.to_xml.must_equal <\
15 | The Gold Plan\
16 | gold\
17 | 7900\
18 |
19 | XML
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/spec/environment.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift lib = File.expand_path('../../lib', __FILE__)
2 |
3 | require 'stringio'
4 | old_stderr, $stderr = $stderr, StringIO.new
5 | at_exit do
6 | $stderr.rewind
7 | $stderr.lines.each { |line| old_stderr.puts line if line.include? lib }
8 | end
9 |
10 | case ENV['XML']
11 | when 'nokogiri' then require 'nokogiri'
12 | end
13 |
14 | require 'recurly'
15 | include Recurly
16 | Recurly.subdomain = 'api'
17 | Recurly.api_key = 'api_key'
18 |
19 | require 'logger'
20 | Recurly.logger = Logger.new nil
21 |
22 |
--------------------------------------------------------------------------------
/doc/yard_extensions.rb:
--------------------------------------------------------------------------------
1 | class DefineAttributeMethodsHandler < YARD::Handlers::Ruby::AttributeHandler
2 | handles method_call(:define_attribute_methods)
3 | namespace_only
4 |
5 | def process
6 | statement.parameters.first.traverse { |child|
7 | next unless child.type == :tstring_content
8 | name = child.source.strip
9 | object = YARD::CodeObjects::MethodObject.new namespace, name
10 | namespace.attributes[:instance][name] = {
11 | :read => object, :write => object
12 | }
13 | }
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/recurly/add_on.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class AddOn < Resource
3 | # @return [Plan]
4 | belongs_to :plan
5 |
6 | define_attribute_methods %w(
7 | add_on_code
8 | name
9 | default_quantity
10 | unit_amount_in_cents
11 | display_quantity_on_hosted_page
12 | created_at
13 | )
14 | alias to_param add_on_code
15 | alias quantity default_quantity
16 |
17 | # Add-ons are only writeable and readable through {Plan} instances.
18 | embedded!
19 | private_class_method :find
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/recurly/all.rb:
--------------------------------------------------------------------------------
1 | require 'recurly'
2 | require 'recurly/account'
3 | require 'recurly/add_on'
4 | require 'recurly/adjustment'
5 | require 'recurly/api'
6 | require 'recurly/billing_info'
7 | require 'recurly/coupon'
8 | require 'recurly/helper'
9 | require 'recurly/invoice'
10 | require 'recurly/js'
11 | require 'recurly/money'
12 | require 'recurly/plan'
13 | require 'recurly/redemption'
14 | require 'recurly/resource'
15 | require 'recurly/resource/pager'
16 | require 'recurly/subscription'
17 | require 'recurly/subscription/add_ons'
18 | require 'recurly/transaction'
19 | require 'recurly/version'
20 | require 'recurly/xml'
21 |
--------------------------------------------------------------------------------
/lib/rails/generators/recurly/config_generator.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class ConfigGenerator < Rails::Generators::Base
3 | desc "Creates a configuration file at config/initializers/recurly.rb"
4 |
5 | # Creates a configuration file at config/initializers/recurly.rb
6 | # when running rails g recurly:config.
7 | def create_recurly_file
8 | create_file 'config/initializers/recurly.rb', <
6 |
7 |
8 |
9 | true
10 | 0
11 | USD
12 | 2011-01-02T03:04:05Z
13 |
14 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | recurly (2.1.8)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | addressable (2.2.6)
10 | bouncy-castle-java (1.5.0146.1)
11 | crack (0.3.1)
12 | jruby-openssl (0.7.4)
13 | bouncy-castle-java
14 | minitest (2.6.1)
15 | nokogiri (1.5.0)
16 | nokogiri (1.5.0-java)
17 | rake (0.9.2)
18 | redcarpet (1.17.2)
19 | webmock (1.7.6)
20 | addressable (~> 2.2, > 2.2.5)
21 | crack (>= 0.1.7)
22 | yard (0.7.3)
23 |
24 | PLATFORMS
25 | java
26 | ruby
27 |
28 | DEPENDENCIES
29 | jruby-openssl (~> 0.7.4)
30 | minitest (~> 2.6.1)
31 | nokogiri (~> 1.5.0)
32 | rake (~> 0.9.2)
33 | recurly!
34 | redcarpet
35 | webmock (~> 1.7.6)
36 | yard
37 |
--------------------------------------------------------------------------------
/spec/recurly_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Recurly do
4 | describe "api key" do
5 | before { @old_api_key = Recurly.api_key }
6 | after { Recurly.api_key = @old_api_key }
7 |
8 | it "must be assignable" do
9 | Recurly.api_key = 'new_key'
10 | Recurly.api_key.must_equal 'new_key'
11 | end
12 |
13 | it "must raise an exception when not set" do
14 | if Recurly.instance_variable_defined? :@api_key
15 | Recurly.send :remove_instance_variable, :@api_key
16 | end
17 | proc { Recurly.api_key }.must_raise ConfigurationError
18 | end
19 |
20 | it "must raise an exception when set to nil" do
21 | Recurly.api_key = nil
22 | proc { Recurly.api_key }.must_raise ConfigurationError
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/recurly/plan.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Plan < Resource
3 | # @return [Pager, []]
4 | has_many :add_ons
5 |
6 | define_attribute_methods %w(
7 | plan_code
8 | name
9 | description
10 | success_url
11 | cancel_url
12 | display_donation_amounts
13 | display_quantity
14 | display_phone_number
15 | bypass_hosted_confirmation
16 | unit_name
17 | payment_page_tos_link
18 | payment_page_css
19 | setup_fee_in_cents
20 | unit_amount_in_cents
21 | plan_interval_length
22 | plan_interval_unit
23 | trial_interval_length
24 | trial_interval_unit
25 | total_billing_cycles
26 | accounting_code
27 | created_at
28 | )
29 | alias to_param plan_code
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/recurly/coupon_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Coupon do
4 | let(:coupon) { Coupon.find 'bettercallsaul' }
5 |
6 | before do
7 | stub_api_request :get, 'coupons/bettercallsaul', 'coupons/show-200'
8 | end
9 |
10 | describe ".find" do
11 | it "must return a coupon when available" do
12 | coupon.must_be_instance_of Coupon
13 | coupon.plan_codes.must_equal ['saul_good']
14 | end
15 | end
16 |
17 | describe "#save" do
18 | it "must not save a new record" do
19 | proc { coupon.save }.must_raise Error
20 | end
21 | end
22 |
23 | describe "#redeem" do
24 | it "must be redeemable" do
25 | stub_api_request(
26 | :put, "coupons/bettercallsaul/redeem", "redemptions/create-201"
27 | )
28 | coupon.redeem 'xX_pinkman_Xx'
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/recurly/money_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Money do
4 | describe "#initialize" do
5 | it "must instantiate with a hash representing currencies" do
6 | money = Money.new :USD => 5_00, :EUR => 3_00
7 | money.must_be_instance_of Money
8 | money[:USD].must_equal 5_00
9 | money[:EUR].must_equal 3_00
10 | end
11 |
12 | it "must instantiate with an integer representing a default currency" do
13 | Recurly.default_currency = 'USD'
14 | money = Money.new 1_00
15 | money.must_be_instance_of Money
16 | money[:USD].must_equal 1_00
17 | end
18 | end
19 |
20 | describe "#to_i" do
21 | it "must return money in cents unless multicurrency" do
22 | Money.new(1).to_i.must_equal 1
23 | proc { Money.new(:USD => 1, :EUR => 2).to_i }.must_raise TypeError
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/recurly.gemspec:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
2 | require 'recurly/version'
3 |
4 | Gem::Specification.new do |s|
5 | s.name = 'recurly'
6 | s.version = Recurly::Version.to_s
7 | s.summary = 'Recurly API Client'
8 | s.description = 'An API client library for Recurly: http://recurly.com'
9 |
10 | s.files = Dir['lib/**/*']
11 |
12 | s.executables = %w(recurly)
13 |
14 | s.has_rdoc = true
15 | s.extra_rdoc_files = %w(README.markdown)
16 | s.rdoc_options = %w(--main README.markdown)
17 |
18 | s.author = 'Recurly'
19 | s.email = 'support@recurly.com'
20 | s.homepage = 'https://github.com/recurly/recurly-client-ruby'
21 | s.license = 'MIT'
22 |
23 | s.add_development_dependency 'rake', '~> 0.9.2'
24 | s.add_development_dependency 'minitest', '~> 2.6.1'
25 | s.add_development_dependency 'webmock', '~> 1.7.6'
26 | end
27 |
--------------------------------------------------------------------------------
/lib/recurly/subscription_add_on.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class SubscriptionAddOn < Resource
3 | define_attribute_methods %w(
4 | add_on_code
5 | quantity
6 | unit_amount_in_cents
7 | )
8 |
9 | attr_reader :subscription
10 |
11 | def initialize add_on = nil, subscription = nil
12 | super()
13 |
14 | case add_on
15 | when AddOn, SubscriptionAddOn
16 | self.add_on_code = add_on.add_on_code
17 | self.quantity = add_on.quantity
18 | if add_on.unit_amount_in_cents
19 | self.unit_amount_in_cents = add_on.unit_amount_in_cents.to_i
20 | end
21 | when Hash
22 | self.attributes = add_on
23 | when String, Symbol
24 | self.add_on_code = add_on
25 | end
26 |
27 | self.add_on_code = add_on_code.to_s
28 |
29 | @subscription = subscription
30 | end
31 |
32 | def currency
33 | subscription.currency if subscription
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/recurly/redemption.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | # Redemptions are not top-level resources, but they can be accessed (and
3 | # created) through {Coupon} instances.
4 | #
5 | # @example
6 | # coupon = Coupon.find "summer2011"
7 | # coupon.redemptions.each { |r| p r }
8 | # coupon.redeem Account.find("groupon_lover")
9 | class Redemption < Resource
10 | # @return [Coupon]
11 | belongs_to :coupon
12 | # @return [Account]
13 | belongs_to :account, :readonly => false
14 |
15 | define_attribute_methods %w(
16 | single_use
17 | total_discounted_in_cents
18 | currency
19 | state
20 | created_at
21 | )
22 |
23 | def save
24 | return false if persisted?
25 | copy_from coupon.redeem account, currency
26 | true
27 | rescue Recurly::API::UnprocessableEntity => e
28 | apply_errors e
29 | false
30 | end
31 |
32 | # Redemptions are only writeable through {Coupon} instances.
33 | embedded!
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/spec/recurly/billing_info_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe BillingInfo do
4 |
5 | describe ".find" do
6 | it "must return an account's billing info when available" do
7 | stub_api_request(
8 | :get, 'accounts/abcdef1234567890/billing_info', 'billing_info/show-200'
9 | )
10 | billing_info = BillingInfo.find 'abcdef1234567890'
11 | billing_info.must_be_instance_of BillingInfo
12 | billing_info.first_name.must_equal 'Larry'
13 | billing_info.last_name.must_equal 'David'
14 | billing_info.card_type.must_equal 'Visa'
15 | billing_info.last_four.must_equal '1111'
16 | billing_info.city.must_equal 'Los Angeles'
17 | billing_info.state.must_equal 'CA'
18 | end
19 |
20 | it "must raise an exception when unavailable" do
21 | stub_api_request(
22 | :get, 'accounts/abcdef1234567890/billing_info', 'accounts/show-404'
23 | )
24 | proc { BillingInfo.find 'abcdef1234567890' }.must_raise Resource::NotFound
25 | end
26 | end
27 |
28 | end
29 |
--------------------------------------------------------------------------------
/spec/fixtures/adjustments/show-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 | abcdef1234567890
8 | pending
9 |
10 | plan
11 | $12 Annual Subscription
12 | 1200
13 | 1
14 | 0
15 | 0
16 | 1200
17 | USD
18 | false
19 | 2011-04-30T07:00:00Z
20 | 2011-04-30T07:00:00Z
21 | 2011-08-31T03:30:00Z
22 |
23 |
--------------------------------------------------------------------------------
/lib/recurly/xml/rexml.rb:
--------------------------------------------------------------------------------
1 | require 'rexml/document'
2 |
3 | module Recurly
4 | class XML
5 | module REXMLAdapter
6 | def initialize xml
7 | @root = ::REXML::Document.new(xml).root
8 | end
9 |
10 | def add_element name, value = nil
11 | node = root.add_element name
12 | node.text = value if value
13 | node
14 | end
15 |
16 | def each_element xpath = nil
17 | root.each_element(xpath) { |el| yield el }
18 | end
19 |
20 | def each element = root
21 | element.each_element do |el|
22 | yield el
23 | each el, &Proc.new
24 | end
25 | end
26 |
27 | def name
28 | root.name
29 | end
30 |
31 | def [] xpath
32 | root.get_elements(xpath).first
33 | end
34 |
35 | def text xpath = nil
36 | text = root.get_text(xpath) and text.to_s
37 | end
38 |
39 | def text= text
40 | root.text = text
41 | end
42 |
43 | def to_s
44 | root.to_s
45 | end
46 | end
47 |
48 | include REXMLAdapter
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/spec/fixtures/coupons/show-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 | bettercallsaul
8 | Saul Goodman Referral
9 | maxed_out
10 | dollars
11 |
12 | 1000
13 |
14 | 2020-05-01T08:00:00Z
15 | true
16 | 1
17 | 100
18 | true
19 | 2011-04-30T08:00:00Z
20 |
21 | saul_good
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/spec/fixtures/billing_info/show-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 | Larry
8 | David
9 |
10 | 123 Pretty Pretty Good St.
11 |
12 | Los Angeles
13 | CA
14 | 90210
15 | US
16 |
17 | 2000
18 | 127.0.0.1
19 |
20 | Visa
21 | 2015
22 | 1
23 | 2010
24 | 12
25 | 20
26 | 411111
27 | 1111
28 |
29 |
--------------------------------------------------------------------------------
/spec/recurly/adjustment_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Adjustment do
4 | describe ".find" do
5 | it "must return an adjustment when available" do
6 | stub_api_request(
7 | :get, 'adjustments/abcdef1234567890', 'adjustments/show-200'
8 | )
9 | adjustment = Adjustment.find 'abcdef1234567890'
10 | adjustment.must_be_instance_of Adjustment
11 | adjustment.type.must_equal 'charge'
12 | adjustment.quantity.must_equal 1
13 | adjustment.unit_amount_in_cents.to_i.must_equal 1200
14 | adjustment.discount_in_cents.to_i.must_equal 0
15 | adjustment.tax_in_cents.to_i.must_equal 0
16 | adjustment.currency.must_equal 'USD'
17 | adjustment.taxable?.must_equal false
18 | adjustment.start_date.must_be_kind_of DateTime
19 | adjustment.end_date.must_be_kind_of DateTime
20 | adjustment.created_at.must_be_kind_of DateTime
21 | end
22 |
23 | it "must raise an exception when unavailable" do
24 | stub_api_request :get, 'adjustments/abcdef1234567890', 'adjustments/show-404'
25 | proc { Adjustment.find 'abcdef1234567890' }.must_raise Resource::NotFound
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/show-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | abcdef1234567890
13 | shmohawk58
14 | larry.david@example.com
15 | Larry
16 | David
17 | Home Box Office
18 | en-US
19 | 18d935f06b0547ddad8cdf2490ac802e
20 | 2011-04-30T12:00:00Z
21 |
22 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/update-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 | Location: https://api.recurly.com/v2/accounts/abcdef1234567890.xml
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | abcdef1234567890
14 | shmohawk58
15 | larry.david@example.com
16 | Larry
17 | David
18 | Home Box Office
19 | en-US
20 | 19d935f06b0547ddad8cdf2490ac802e
21 | 2011-04-30T12:00:00Z
22 |
23 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/create-201.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 201 Created
2 | Content-Type: application/xml; charset=utf-8
3 | Location: https://api.recurly.com/v2/accounts/abcdef1234567890.xml
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | abcdef1234567890
14 | shmohawk58
15 | larry.david@example.com
16 | Larry
17 | David
18 | Home Box Office
19 | en-US
20 | 18d935f06b0547ddad8cdf2490ac802e
21 | 2011-04-30T12:00:00Z
22 |
23 |
--------------------------------------------------------------------------------
/spec/fixtures/subscriptions/show-200-inactive.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 |
8 | plan_code
9 | A Man, a Plan, a Canal: Panama
10 |
11 | abcdef1234567890
12 | canceled
13 | 1
14 | 1000
15 | 2011-04-30T07:00:00Z
16 |
17 |
18 | 2011-04-01T07:00:00Z
19 | 2011-05-01T06:59:59Z
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/lib/recurly/xml/nokogiri.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class XML
3 | module NokogiriAdapter
4 | def initialize xml
5 | @root = Nokogiri(xml).root
6 | end
7 |
8 | def add_element name, value = nil
9 | root.add_child(node = ::Nokogiri::XML::Element.new(name, root))
10 | node << value if value
11 | node
12 | end
13 |
14 | def each_element xpath = nil
15 | elements = xpath.nil? ? root.children : root.xpath(xpath)
16 | elements.each { |el| yield el }
17 | end
18 |
19 | def each element = root
20 | element.elements.each do |el|
21 | yield el
22 | each el, &Proc.new
23 | end
24 | end
25 |
26 | def name
27 | root.name
28 | end
29 |
30 | def [] xpath
31 | root.at_xpath xpath
32 | end
33 |
34 | def text xpath = nil
35 | if node = (xpath ? root.at_xpath(xpath) : root)
36 | if node.text?
37 | node.text
38 | else
39 | node.children.map { |e| e.text if e.text? }.compact.join
40 | end
41 | end
42 | end
43 |
44 | def text= text
45 | root.content = text
46 | end
47 |
48 | def to_s
49 | root.to_xml(:indent => 0).gsub(/$\n/, '')
50 | end
51 | end
52 |
53 | include NokogiriAdapter
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/recurly/helper.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | module Helper
3 | def camelize underscored_word
4 | underscored_word.to_s.gsub(/(?:^|_)(.)/) { $1.upcase }
5 | end
6 |
7 | def classify table_name
8 | camelize singularize(table_name.to_s.sub(/.*\./, ''))
9 | end
10 |
11 | def demodulize class_name_in_module
12 | class_name_in_module.to_s.sub(/^.*::/, '')
13 | end
14 |
15 | def pluralize word
16 | word.to_s.sub(/([^s])$/, '\1s')
17 | end
18 |
19 | def singularize word
20 | word.to_s.sub(/s$/, '').sub(/ie$/, 'y')
21 | end
22 |
23 | def underscore camel_cased_word
24 | word = camel_cased_word.to_s.dup
25 | word.gsub!(/::/, '/')
26 | word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
27 | word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
28 | word.tr! "-", "_"
29 | word.downcase!
30 | word
31 | end
32 |
33 | def hash_with_indifferent_read_access base = {}
34 | indifferent = Hash.new { |hash, key| hash[key.to_s] if key.is_a? Symbol }
35 | base.each_pair { |key, value| indifferent[key.to_s] = value }
36 | indifferent
37 | end
38 |
39 | def stringify_keys! hash
40 | hash.keys.each do |key|
41 | stringify_keys! hash[key] if hash[key].is_a? Hash
42 | hash[key.to_s] = hash.delete key if key.is_a? Symbol
43 | end
44 | end
45 |
46 | extend self
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/spec/fixtures/subscriptions/show-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 |
8 | plan_code
9 | A Man, a Plan, a Canal: Panama
10 |
11 | abcdef1234567890
12 | active
13 | 1
14 | 1000
15 | 2011-04-30T07:00:00Z
16 |
17 |
18 | 2011-04-01T07:00:00Z
19 | 2011-05-01T06:59:59Z
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/spec/recurly/transaction_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Transaction do
4 | describe ".find" do
5 | it "must return a transaction when available" do
6 | stub_api_request(
7 | :get, 'transactions/abcdef1234567890', 'transactions/show-200'
8 | )
9 | transaction = Transaction.find 'abcdef1234567890'
10 | transaction.must_be_instance_of Transaction
11 | end
12 | end
13 |
14 | describe "#save" do
15 | it "must re-raise a transaction error" do
16 | stub_api_request :post, 'transactions', 'transaction_error'
17 | transaction = Transaction.new :account => {
18 | :account_code => 'test',
19 | :billing_info => {
20 | :credit_card_number => '4111111111111111'
21 | }
22 | }
23 | error = proc { transaction.save }.must_raise Transaction::DeclinedError
24 | error.message.must_equal(
25 | "Your card number is not valid. Please update your card number."
26 | )
27 | transaction.account.billing_info.errors[:credit_card_number].wont_be_nil
28 | error.transaction_error_code.must_equal 'invalid_card_number'
29 | error.transaction.must_equal transaction
30 | transaction.persisted?.must_equal true
31 | end
32 |
33 | it "won't save a persisted transaction" do
34 | transaction = Transaction.new
35 | transaction.persist!
36 | proc { transaction.save }.must_raise Error
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/recurly/adjustment.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Adjustment < Resource
3 | # @macro [attach] scope
4 | # @scope class
5 | # @return [Pager] a pager that yields +$1+.
6 | scope :charges, :type => 'charge'
7 | scope :credits, :type => 'credit'
8 |
9 | scope :pending, :state => 'pending'
10 | scope :invoiced, :state => 'invoiced'
11 |
12 | # @return [Account, nil]
13 | belongs_to :account
14 | # @return [Invoice, nil]
15 | belongs_to :invoice
16 |
17 | define_attribute_methods %w(
18 | uuid
19 | state
20 | description
21 | accounting_code
22 | origin
23 | unit_amount_in_cents
24 | quantity
25 | discount_in_cents
26 | tax_in_cents
27 | total_in_cents
28 | currency
29 | taxable
30 | start_date
31 | end_date
32 | created_at
33 | )
34 | alias to_param uuid
35 |
36 | # @return ["charge", "credit", nil] The type of adjustment.
37 | attr_reader :type
38 |
39 | # Adjustments should be built through {Account} instances.
40 | #
41 | # @return [Adjustment] A new adjustment.
42 | # @example
43 | # account.adjustments.new attributes
44 | # @see Resource#initialize
45 | def initialize attributes = {}
46 | super({ :currency => Recurly.default_currency }.merge attributes)
47 | end
48 |
49 | # Adjustments are only writeable through an {Account} instance.
50 | embedded! true
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/recurly/billing_info.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class BillingInfo < Resource
3 | # @return [Account]
4 | belongs_to :account
5 |
6 | define_attribute_methods %w(
7 | first_name
8 | last_name
9 | company
10 | address1
11 | address2
12 | city
13 | state
14 | zip
15 | country
16 | phone
17 | vat_number
18 | ip_address
19 | ip_address_country
20 | card_type
21 | year
22 | month
23 | start_year
24 | start_month
25 | issue_number
26 | first_six
27 | last_four
28 | paypal_billing_agreement_id
29 | number
30 | verification_value
31 | )
32 |
33 | # @return ["credit_card", "paypal", nil] The type of billing info.
34 | attr_reader :type
35 |
36 | # @return [String]
37 | def inspect
38 | attributes = self.class.attribute_names
39 | case type
40 | when 'credit_card'
41 | attributes -= %w(paypal_billing_agreement_id)
42 | when 'paypal'
43 | attributes -= %w(
44 | card_type year month start_year start_month issue_number
45 | first_six last_four
46 | )
47 | end
48 | super attributes
49 | end
50 |
51 | class << self
52 | # Overrides the inherited member_path method to allow for billing info's
53 | # irregular URL structure.
54 | #
55 | # @return [String] The relative path to an account's billing info from the
56 | # API's base URI.
57 | # @param uuid [String]
58 | # @example
59 | # Recurly::BillingInfo.member_path "code"
60 | # # => "accounts/code/billing_info"
61 | def member_path uuid
62 | "accounts/#{uuid}/billing_info"
63 | end
64 | end
65 |
66 | # Billing info is only writeable through an {Account} instance.
67 | embedded!
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/spec/fixtures/transactions/show-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 | abcdef1234567890
8 | purchase
9 | 30000
10 | 0
11 | USD
12 | success
13 | subscription
14 |
15 | true
16 | true
17 | true
18 | true
19 |
20 |
21 |
22 |
23 | 2011-04-30T12:00:00Z
24 |
25 |
26 | gob
27 | George Oscar
28 | Bluth
29 |
30 | gobias@bluth-company.com
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Visa
43 | 2011
44 | 12
45 | 411111
46 | 1111
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/lib/recurly/account.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Account < Resource
3 | # @macro [attach] scope
4 | # @scope class
5 | # @return [Pager] A pager that yields +$1+ accounts.
6 | scope :active, :state => :active
7 | scope :closed, :state => :closed
8 | scope :subscriber, :state => :subscriber
9 | scope :non_subscriber, :state => :non_subscriber
10 | scope :past_due, :state => :past_due
11 |
12 | # @macro [attach] has_many
13 | # @return [Pager, []] A pager that yields $1 for persisted
14 | # accounts; an empty array otherwise.
15 | has_many :adjustments
16 | has_many :invoices
17 | has_many :subscriptions
18 | has_many :transactions
19 |
20 | # @return [BillingInfo, nil]
21 | has_one :billing_info, :readonly => false
22 |
23 | # @return [Redemption, nil]
24 | has_one :redemption
25 |
26 | define_attribute_methods %w(
27 | account_code
28 | state
29 | username
30 | email
31 | first_name
32 | last_name
33 | company_name
34 | accept_language
35 | hosted_login_token
36 | created_at
37 | )
38 | alias to_param account_code
39 |
40 | # @return [Invoice] A newly-created invoice.
41 | # @raise [Invalid] Raised if the account cannot be invoiced.
42 | def invoice!
43 | Invoice.from_response API.post(invoices.uri)
44 | rescue Recurly::API::UnprocessableEntity => e
45 | raise Invalid, e.message
46 | end
47 |
48 | # Reopen an account.
49 | #
50 | # @return [true, false] +true+ when successful, +false+ when unable to
51 | # (e.g., the account is already opwn), and may raise an exception if the
52 | # attempt fails.
53 | def reopen
54 | return false unless self[:reopen]
55 | reload self[:reopen].call
56 | true
57 | end
58 |
59 | private
60 |
61 | def xml_keys
62 | keys = super
63 | keys << 'account_code' if account_code? && !account_code_changed?
64 | keys.sort
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/lib/recurly/invoice.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | # Invoices are created through account objects.
3 | #
4 | # @example
5 | # account = Account.find account_code
6 | # account.invoice!
7 | class Invoice < Resource
8 | # @macro [attach] scope
9 | # @scope class
10 | # @return [Pager] A pager that yields +$1+ invoices.
11 | scope :open, :state => :open
12 | scope :collected, :state => :collected
13 | scope :failed, :state => :failed
14 | scope :past_due, :state => :past_due
15 |
16 | # @return [Account]
17 | belongs_to :account
18 |
19 | # @return [Redemption]
20 | has_one :redemption
21 |
22 | define_attribute_methods %w(
23 | uuid
24 | state
25 | invoice_number
26 | po_number
27 | vat_number
28 | subtotal_in_cents
29 | tax_in_cents
30 | total_in_cents
31 | currency
32 | created_at
33 | line_items
34 | transactions
35 | )
36 | alias to_param invoice_number
37 |
38 | # Marks an invoice as paid successfully.
39 | #
40 | # @return [true, false] +true+ when successful, +false+ when unable to
41 | # (e.g., the invoice is no longer open).
42 | def mark_successful
43 | return false unless self[:mark_successful]
44 | reload self[:mark_successful].call
45 | true
46 | end
47 |
48 | # Marks an invoice as failing collection.
49 | #
50 | # @return [true, false] +true+ when successful, +false+ when unable to
51 | # (e.g., the invoice is no longer open).
52 | def mark_failed
53 | return false unless self[:mark_failed]
54 | reload self[:mark_failed].call
55 | true
56 | end
57 |
58 | def pdf
59 | self.class.find to_param, :format => 'pdf'
60 | end
61 |
62 | private
63 |
64 | def initialize attributes = {}
65 | super({ :currency => Recurly.default_currency }.merge attributes)
66 | end
67 |
68 | # Invoices are only writeable through {Account} instances.
69 | embedded! true
70 | undef save
71 | undef destroy
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/spec/recurly/xml_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Recurly::XML do
4 | describe ".filter" do
5 | it "must filter sensitive data only on number and verification_value" do
6 | [
7 | [
8 | '4111111111111111',
9 | '************1111'
10 | ],
11 | [
12 | '4111-1111-1111-1111',
13 | '****-****-****-1111'
14 | ],
15 | [
16 | '123',
17 | ''
18 | ],
19 | [
20 | '123',
21 | '***'
22 | ],
23 | [
24 | 'DE123456789',
25 | 'DE123456789'
26 | ]
27 | ].each do |input, output|
28 | Recurly::XML.filter(input).must_equal output
29 | end
30 | end
31 | end
32 |
33 | describe ".text" do
34 | before :each do
35 | @sample_xml = Recurly::XML.new("Text from nodeText from root")
36 | end
37 |
38 | it "should return the first child text node" do
39 | @sample_xml.text.must_equal "Text from root"
40 | end
41 |
42 | it "should return the first child text node at the given xpath" do
43 | @sample_xml.text("//node").must_equal "Text from node"
44 | end
45 |
46 | it "should return nil if no node is found" do
47 | @sample_xml.text("//idontexist").must_be_nil
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require 'environment'
2 | require 'cgi'
3 | require 'minitest/autorun'
4 | require 'minitest/spec'
5 | require 'webmock'
6 |
7 | module SpecHelper
8 | include WebMock::API
9 |
10 | def stub_api_request method, uri, fixture = nil
11 | uri = API.base_uri + uri
12 | uri.user = CGI.escape Recurly.api_key
13 | uri.password = ''
14 | response = if block_given?
15 | yield
16 | else
17 | File.read File.expand_path("../fixtures/#{fixture}.xml", __FILE__)
18 | end
19 | stub_request(method, uri.to_s).to_return response
20 | end
21 | end
22 | include SpecHelper
23 |
24 | XML = {
25 | 200 => {
26 | :index => [
27 | <; rel="next"
31 | X-Records: 3
32 |
33 |
34 |
35 | 1
36 |
37 |
38 | 2
39 |
40 |
41 | EOR
42 | <; rel="start"
46 | X-Records: 3
47 |
48 |
49 |
50 | 3
51 |
52 |
53 | EOR
54 | ],
55 | :show => <
59 | Spock
60 |
61 | EOR
62 | :update => <
66 | Persistent Little Bug
67 |
68 | EOR
69 | :destroy => < <
79 | Persistent Little Bug
80 |
81 | EOR
82 | 422 => <
87 | is a bad name
88 |
89 | EOR
90 | 404 => <
95 | not_found
96 | Resource not found
97 |
98 | EOR
99 | }
100 |
--------------------------------------------------------------------------------
/bin/recurly:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
3 |
4 | module CLI
5 | def version
6 | require 'recurly/version'
7 | "Recurly v#{Recurly::Version::VERSION}"
8 | end
9 |
10 | def clear
11 | print `clear`
12 | end
13 | end
14 | include CLI
15 |
16 | require 'optparse'
17 | options = {}
18 | OptionParser.new do |opts|
19 | opts.banner = 'Usage: recurly [options] -- [irb options]'
20 |
21 | opts.on '-s', '--subdomain [subdomain]', 'Your subdomain' do |subdomain|
22 | options[:subdomain] = subdomain
23 | end
24 |
25 | opts.on '-k', '--api-key [api key]', 'Your API key' do |key|
26 | options[:api_key] = key
27 | end
28 |
29 | opts.on '-K', '--private-key [private key]', 'Your Recurly.js private key' do |key|
30 | options[:private_key] = key
31 | end
32 |
33 | opts.on '-v', '--verbose', 'Show full request/response log' do |verbose|
34 | options[:verbose] = verbose
35 | end
36 |
37 | opts.on(
38 | '-e', '--exec [code]', 'Execute a line of code before the session'
39 | ) do |line|
40 | options[:exec] = line
41 | end
42 |
43 | opts.separator nil
44 |
45 | opts.on '-h', '--help', 'Display this screen' do
46 | puts opts
47 | exit
48 | end
49 |
50 | opts.on '--version', 'The current version' do
51 | puts version
52 | exit
53 | end
54 | end.parse!
55 |
56 | require 'recurly/all'
57 | Recurly.subdomain = options[:subdomain] || ENV['RECURLY_SUBDOMAIN']
58 | Recurly.api_key = options[:api_key] || ENV['RECURLY_API_KEY']
59 | Recurly.js.private_key = options[:private_key] || ENV['RECURLY_JS_PRIVATE_KEY']
60 | include Recurly
61 |
62 | require 'logger'
63 | Recurly.logger = Logger.new STDOUT
64 | Recurly.logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
65 |
66 | require 'irb'
67 | require 'irb/completion'
68 |
69 | class << IRB
70 | alias old_setup setup
71 | def setup ap_path
72 | old_setup ap_path
73 | conf[:PROMPT][:RECURLY] = {
74 | :PROMPT_N => 'recurly> ',
75 | :PROMPT_I => 'recurly> ',
76 | :PROMPT_S => nil,
77 | :PROMPT_C => ' ?> ',
78 | :RETURN => " => %s\n"
79 | }
80 | conf[:PROMPT_MODE] = :RECURLY
81 | end
82 | end
83 |
84 | puts version
85 | eval options[:exec] if options[:exec]
86 | IRB.start $0
87 | exit!
88 |
--------------------------------------------------------------------------------
/lib/recurly/coupon.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Coupon < Resource
3 | # @macro [attach] scope
4 | # @scope class
5 | # @return [Pager] A pager that yields +$1+ coupons.
6 | scope :redeemable, :state => :redeemable
7 | scope :expired, :state => :expired
8 | scope :maxed_out, :state => :maxed_out
9 |
10 | # @return [Pager, []]
11 | has_many :redemptions
12 |
13 | define_attribute_methods %w(
14 | coupon_code
15 | name
16 | state
17 | discount_type
18 | discount_percent
19 | discount_in_cents
20 | redeem_by_date
21 | single_use
22 | applies_for_months
23 | max_redemptions
24 | applies_to_all_plans
25 | created_at
26 | plan_codes
27 | description
28 | invoice_description
29 | )
30 | alias to_param coupon_code
31 |
32 | # Saves new records only.
33 | #
34 | # @return [true, false]
35 | # @raise [Recurly::Error] For persisted coupons.
36 | # @see Resource#save
37 | def save
38 | return super if new_record?
39 | raise Recurly::Error, "#{self.class.collection_name} cannot be updated"
40 | end
41 |
42 | # Redeem a coupon with a given account or account code.
43 | #
44 | # @return [true]
45 | # @param account_or_code [Account, String]
46 | # @example
47 | # coupon = Coupon.find coupon_code
48 | # coupon.redeem account_code
49 | #
50 | # coupon = Coupon.find coupon_code
51 | # account = Account.find account_code
52 | # coupon.redeem account
53 | def redeem account_or_code, currency = nil
54 | return false unless self[:redeem]
55 |
56 | account_code = if account_or_code.is_a? Account
57 | account_or_code.account_code
58 | else
59 | account_or_code
60 | end
61 |
62 | Redemption.from_response self[:redeem].call(
63 | :body => (redemption = redemptions.new(
64 | :account_code => account_code,
65 | :currency => currency || Recurly.default_currency
66 | )).to_xml
67 | )
68 | rescue API::UnprocessableEntity => e
69 | redemption.apply_errors e
70 | redemption
71 | end
72 |
73 | def redeem! account_code, currency = nil
74 | redemption = redeem account_code, currency
75 | raise Invalid.new(self) unless redemption.persisted?
76 | redemption
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/lib/recurly/subscription/add_ons.rb:
--------------------------------------------------------------------------------
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 :quantity and
18 | # :unit_amount_in_cents 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 | add_on = SubscriptionAddOn.new(add_on, @subscription)
27 |
28 | exist = @add_ons.find { |a| a.add_on_code == add_on.add_on_code }
29 | if exist
30 | exist.quantity ||= 1 and exist.quantity += 1
31 |
32 | if add_on.unit_amount_in_cents
33 | exist.unit_amount_in_cents = add_on.unit_amount_in_cents
34 | end
35 | else
36 | @add_ons << add_on
37 | end
38 |
39 | self
40 | end
41 |
42 | def to_a
43 | @add_ons.dup
44 | end
45 |
46 | def errors
47 | @add_ons.map { |add_on| add_on.errors }
48 | end
49 |
50 | def to_xml options = {}
51 | builder = options[:builder] || XML.new('')
52 | @add_ons.each do |add_on|
53 | node = builder.add_element 'subscription_add_on'
54 | add_on.attributes.each_pair do |k, v|
55 | node.add_element k.to_s, v if v
56 | end
57 | end
58 | builder.to_s
59 | end
60 |
61 | def respond_to? method_name, include_private = false
62 | super || @add_ons.respond_to?(method_name, include_private)
63 | end
64 |
65 | private
66 |
67 | def method_missing name, *args, &block
68 | if @add_ons.respond_to? name
69 | return @add_ons.send name, *args, &block
70 | end
71 |
72 | super
73 | end
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/recurly/xml.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class XML
3 | class << self
4 | def cast el
5 | return if el.attribute 'nil'
6 |
7 | if el.attribute 'type'
8 | type = el.attribute('type').value
9 | end
10 |
11 | case type
12 | when 'array' then el.elements.map { |e| XML.cast e }
13 | when 'boolean' then el.text == 'true'
14 | when 'date' then Date.parse el.text
15 | when 'datetime' then DateTime.parse el.text
16 | when 'float' then el.text.to_f
17 | when 'integer' then el.text.to_i
18 | else
19 | # FIXME: Move some of this logic to Resource.from_xml?
20 | [el.name, type].each do |name|
21 | next unless name
22 | resource_name = Helper.classify name
23 | if Recurly.const_defined? resource_name, false
24 | return Recurly.const_get(resource_name, false).from_xml el
25 | end
26 | end
27 | if el.elements.empty?
28 | el.text
29 | else
30 | Hash[el.elements.map { |e| [e.name, XML.cast(e)] }]
31 | end
32 | end
33 | end
34 |
35 | def filter text
36 | xml = XML.new text
37 | xml.each do |el|
38 | el = XML.new el
39 | case el.name
40 | when "number"
41 | text = el.text.to_s
42 | last = text[-4, 4]
43 | el.text = "#{text[0, text.length - 4].to_s.gsub(/\d/, '*')}#{last}"
44 | when "verification_value"
45 | el.text = el.text.to_s.gsub(/\d/, '*')
46 | end
47 | end
48 | xml.to_s
49 | end
50 | end
51 |
52 | attr_reader :root
53 |
54 | def initialize xml
55 | @root = xml.is_a?(String) ? super : xml
56 | end
57 |
58 | # Adds an element to the root.
59 | def add_element name, value = nil
60 | value = value.respond_to?(:xmlschema) ? value.xmlschema : value.to_s
61 | XML.new super(name, value)
62 | end
63 |
64 | # Iterates over the root's elements.
65 | def each_element xpath = nil
66 | return enum_for :each_element unless block_given?
67 | super
68 | end
69 |
70 | # Returns the root's name.
71 | def name
72 | super
73 | end
74 |
75 | # Returns an XML string.
76 | def to_s
77 | super
78 | end
79 | end
80 | end
81 |
82 | if defined? Nokogiri
83 | require 'recurly/xml/nokogiri'
84 | else
85 | require 'recurly/xml/rexml'
86 | end
87 |
--------------------------------------------------------------------------------
/lib/recurly/js.rb:
--------------------------------------------------------------------------------
1 | require 'openssl'
2 | require 'base64'
3 | require 'cgi'
4 |
5 | module Recurly
6 | # A collection of helper methods to use to verify
7 | # {Recurly.js}[http://js.recurly.com/] callbacks.
8 | module JS
9 | # Raised when signature verification fails.
10 | class RequestForgery < Error
11 | end
12 |
13 | # Raised when the timestamp is over an hour old. Prevents replay attacks.
14 | class RequestTooOld < RequestForgery
15 | end
16 |
17 | class << self
18 | # @return [String] A private key for Recurly.js.
19 | # @raise [ConfigurationError] No private key has been set.
20 | def private_key
21 | defined? @private_key and @private_key or raise(
22 | ConfigurationError, "private_key not configured"
23 | )
24 | end
25 | attr_writer :private_key
26 |
27 | # Create a signature for a given hash for Recurly.js
28 | # @param Array of objects and hash of data to sign
29 | def sign *records
30 | data = records.last.is_a?(Hash) ? records.pop.dup : {}
31 | records.each do |record|
32 | data[record.class.member_name] = record.signable_attributes
33 | end
34 | Helper.stringify_keys! data
35 | data['timestamp'] ||= Time.now.to_i
36 | data['nonce'] ||= Base64.encode64(
37 | OpenSSL::Random.random_bytes(32)
38 | ).gsub(/\W/, '')
39 | unsigned = to_query data
40 | signed = OpenSSL::HMAC.hexdigest 'sha1', private_key, unsigned
41 | signature = [signed, unsigned].join '|'
42 | signature = signature.html_safe if signature.respond_to? :html_safe
43 | signature
44 | end
45 |
46 | # Fetches a record using a token provided by Recurly.js.
47 | # @param [String] Token to look up
48 | # @return [BillingInfo, Invoice, Subscription] The record created or
49 | # modified by Recurly.js
50 | # @raise [API::NotFound] No record was found for the token provided.
51 | # @example
52 | # begin
53 | # Recurly.js.fetch params[:token]
54 | # rescue Recurly::API::NotFound
55 | # # Handle potential tampering here.
56 | # end
57 | def fetch token
58 | Resource.from_response API.get "recurly_js/result/#{token}"
59 | end
60 |
61 | # @return [String]
62 | def inspect
63 | 'Recurly.js'
64 | end
65 |
66 | private
67 |
68 | def to_query object, key = nil
69 | case object
70 | when Hash
71 | object.map { |k, v| to_query v, key ? "#{key}[#{k}]" : k }.sort * '&'
72 | when Array
73 | object.map { |o| to_query o, "#{key}[]" } * '&'
74 | else
75 | "#{CGI.escape key.to_s}=#{CGI.escape object.to_s}"
76 | end
77 | end
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/lib/recurly/transaction.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Transaction < Resource
3 | require 'recurly/transaction/errors'
4 |
5 | # @macro [attach] scope
6 | # @scope class
7 | # @return [Pager] a pager that yields +$1+ transactions.
8 | scope :authorizations, :type => 'authorization'
9 | scope :purchases, :type => 'purchase'
10 | scope :refunds, :type => 'refund'
11 |
12 | scope :successful, :state => 'successful'
13 | scope :failed, :state => 'failed'
14 | scope :voided, :state => 'voided'
15 |
16 | # @return [Account]
17 | belongs_to :account
18 | # @return [Invoice, nil]
19 | belongs_to :invoice
20 | # @return [Subscription, nil]
21 | belongs_to :subscription
22 |
23 | define_attribute_methods %w(
24 | uuid
25 | action
26 | amount_in_cents
27 | tax_in_cents
28 | currency
29 | status
30 | reference
31 | recurring
32 | test
33 | voidable
34 | refundable
35 | cvv_result
36 | avs_result
37 | avs_result_street
38 | created_at
39 | details
40 | transaction_error
41 | source
42 | )
43 | alias to_param uuid
44 |
45 | # @return ["credit", "charge", nil] The type of transaction.
46 | attr_reader :type
47 |
48 | # @see Resource#initialize
49 | def initialize attributes = {}
50 | super({ :currency => Recurly.default_currency }.merge attributes)
51 | end
52 |
53 | # Saves new records only.
54 | #
55 | # @return [true, false]
56 | # @raise [Recurly::Error] For persisted transactions.
57 | # @see Resource#save
58 | def save
59 | return super if new_record?
60 | raise Recurly::Error, "#{self.class.collection_name} cannot be updated"
61 | end
62 |
63 | # Refunds the transaction.
64 | #
65 | # @return [Transaction, false] The updated original transaction if voided,
66 | # a new refund transaction, false if the transaction isn't voidable or
67 | # refundable.
68 | # @raise [Error] If the refund fails.
69 | # @param amount_in_cents [Integer, nil] The amount (in cents) to refund
70 | # (refunds fully if nil).
71 | def refund amount_in_cents = nil
72 | return false unless self[:refund]
73 | refund = self.class.from_response(
74 | self[:refund].call :params => { :amount_in_cents => amount_in_cents }
75 | )
76 | refund.uuid == uuid ? copy_from(refund) && self : refund
77 | end
78 |
79 | def signable_attributes
80 | super.merge :amount_in_cents => amount_in_cents, :currency => currency
81 | end
82 |
83 | # @return [String]
84 | def inspect
85 | attributes = self.class.attribute_names
86 | unless type == 'credit_card'
87 | attributes -= %w(cvv_result avs_result avs_result_street)
88 | end
89 | super attributes
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/lib/recurly/api.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | # The API class handles all requests to the Recurly API. While most of its
3 | # functionality is leveraged by the Resource class, it can be used directly,
4 | # as well.
5 | #
6 | # Requests are made with methods named after the four main HTTP verbs
7 | # recognized by the Recurly API.
8 | #
9 | # @example
10 | # Recurly::API.get 'accounts' # => #
11 | # Recurly::API.post 'accounts', xml_body # => #
12 | # Recurly::API.put 'accounts/1', xml_body # => #
13 | # Recurly::API.delete 'accounts/1' # => #
14 | class API
15 | require 'recurly/api/errors'
16 |
17 | @@base_uri = "https://api.recurly.com/v2/"
18 |
19 | FORMATS = Helper.hash_with_indifferent_read_access(
20 | 'pdf' => 'application/pdf',
21 | 'xml' => 'application/xml'
22 | )
23 |
24 | class << self
25 | # Additional HTTP headers sent with each API call
26 | # @return [Hash{String => String}]
27 | def headers
28 | @headers ||= { 'Accept' => accept, 'User-Agent' => user_agent }
29 | end
30 |
31 | # @return [String, nil] Accept-Language header value
32 | def accept_language
33 | headers['Accept-Language']
34 | end
35 |
36 | # @param [String] language Accept-Language header value
37 | def accept_language=(language)
38 | headers['Accept-Language'] = language
39 | end
40 |
41 | # @return [Net::HTTPOK, Net::HTTPResponse]
42 | # @raise [ResponseError] With a non-2xx status code.
43 | def head uri, params = {}, options = {}
44 | request :head, uri, { :params => params }.merge(options)
45 | end
46 |
47 | # @return [Net::HTTPOK, Net::HTTPResponse]
48 | # @raise [ResponseError] With a non-2xx status code.
49 | def get uri, params = {}, options = {}
50 | request :get, uri, { :params => params }.merge(options)
51 | end
52 |
53 | # @return [Net::HTTPCreated, Net::HTTPResponse]
54 | # @raise [ResponseError] With a non-2xx status code.
55 | def post uri, body = nil, options = {}
56 | request :post, uri, { :body => body.to_s }.merge(options)
57 | end
58 |
59 | # @return [Net::HTTPOK, Net::HTTPResponse]
60 | # @raise [ResponseError] With a non-2xx status code.
61 | def put uri, body = nil, options = {}
62 | request :put, uri, { :body => body.to_s }.merge(options)
63 | end
64 |
65 | # @return [Net::HTTPNoContent, Net::HTTPResponse]
66 | # @raise [ResponseError] With a non-2xx status code.
67 | def delete uri, options = {}
68 | request :delete, uri, options
69 | end
70 |
71 | # @return [URI::Generic]
72 | def base_uri
73 | URI.parse @@base_uri.sub('api', Recurly.subdomain)
74 | end
75 |
76 | # @return [String]
77 | def user_agent
78 | "Recurly/#{Version}; #{RUBY_DESCRIPTION}"
79 | end
80 |
81 | private
82 |
83 | def accept
84 | FORMATS['xml']
85 | end
86 | alias content_type accept
87 | end
88 | end
89 | end
90 |
91 | require 'recurly/api/net_http_adapter'
92 |
--------------------------------------------------------------------------------
/lib/recurly.rb:
--------------------------------------------------------------------------------
1 | # Recurly is a Ruby client for Recurly's REST API.
2 | module Recurly
3 | autoload :Account, 'recurly/account'
4 | autoload :AddOn, 'recurly/add_on'
5 | autoload :Adjustment, 'recurly/adjustment'
6 | autoload :API, 'recurly/api'
7 | autoload :BillingInfo, 'recurly/billing_info'
8 | autoload :Coupon, 'recurly/coupon'
9 | autoload :Helper, 'recurly/helper'
10 | autoload :Invoice, 'recurly/invoice'
11 | autoload :JS, 'recurly/js'
12 | autoload :Money, 'recurly/money'
13 | autoload :Plan, 'recurly/plan'
14 | autoload :Redemption, 'recurly/redemption'
15 | autoload :Resource, 'recurly/resource'
16 | autoload :Subscription, 'recurly/subscription'
17 | autoload :SubscriptionAddOn, 'recurly/subscription_add_on'
18 | autoload :Transaction, 'recurly/transaction'
19 | autoload :Version, 'recurly/version'
20 | autoload :XML, 'recurly/xml'
21 |
22 | @subdomain = nil
23 |
24 | # The exception class from which all Recurly exceptions inherit.
25 | class Error < StandardError
26 | def set_message message
27 | @message = message
28 | end
29 |
30 | # @return [String]
31 | def to_s
32 | defined? @message and @message or super
33 | end
34 | end
35 |
36 | # This exception is raised if Recurly has not been configured.
37 | class ConfigurationError < Error
38 | end
39 |
40 | class << self
41 | # @return [String] A subdomain.
42 | def subdomain
43 | @subdomain || 'api'
44 | end
45 | attr_writer :subdomain
46 |
47 | # @return [String] An API key.
48 | # @raise [ConfigurationError] If not configured.
49 | def api_key
50 | defined? @api_key and @api_key or raise(
51 | ConfigurationError, "Recurly.api_key not configured"
52 | )
53 | end
54 | attr_writer :api_key
55 |
56 | # @return [String, nil] A default currency.
57 | def default_currency
58 | return @default_currency if defined? @default_currency
59 | @default_currency = 'USD'
60 | end
61 | attr_writer :default_currency
62 |
63 | # @return [JS] The Recurly.js module.
64 | def js
65 | JS
66 | end
67 |
68 | # Assigns a logger to log requests/responses and more.
69 | #
70 | # @return [Logger, nil]
71 | # @example
72 | # require 'logger'
73 | # Recurly.logger = Logger.new STDOUT
74 | # @example Rails applications automatically log to the Rails log:
75 | # Recurly.logger = Rails.logger
76 | # @example Turn off logging entirely:
77 | # Recurly.logger = nil # Or Recurly.logger = Logger.new nil
78 | attr_accessor :logger
79 |
80 | # Convenience logging method includes a Logger#progname dynamically.
81 | # @return [true, nil]
82 | def log level, message
83 | logger.send(level, name) { message }
84 | end
85 |
86 | if RUBY_VERSION <= "1.9.0"
87 | def const_defined? sym, inherit = false
88 | raise ArgumentError, "inherit must be false" if inherit
89 | super sym
90 | end
91 |
92 | def const_get sym, inherit = false
93 | raise ArgumentError, "inherit must be false" if inherit
94 | super sym
95 | end
96 | end
97 | end
98 | end
99 |
100 | require 'rails/recurly' if defined? Rails::Railtie
101 |
--------------------------------------------------------------------------------
/spec/fixtures/transaction_error.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 422 Unprocessable Entity
2 | Content-Type: application/xml; charset=utf-8
3 |
4 |
5 |
6 |
7 | invalid_card_number
8 | declined
9 | Your card number is not valid. Please update your card number.
10 | The credit card number is not valid. The customer needs to try a different number.
11 |
12 | is not valid
13 |
14 | abcdef1234567890
15 | purchase
16 | 100
17 | 0
18 | USD
19 | declined
20 |
21 | true
22 | true
23 | false
24 | false
25 |
26 | invalid_card_number
27 | hard
28 | The credit card number is not valid. The customer needs to try a different number.
29 | Your card number is not valid. Please update your card number.
30 |
31 |
32 |
33 |
34 |
35 | 2011-04-30T12:00:00Z
36 |
37 |
38 | maeby
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | Visa
55 | 2011
56 | 4
57 | 411111
58 | 1111
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/lib/recurly/transaction/errors.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Transaction < Resource
3 | # The base error class for transaction errors, raised when a transaction
4 | # fails.
5 | #
6 | # Error messages are customer-friendly, though only {DeclinedError}
7 | # messages should be a part of the normal API flow (a {ConfigurationError},
8 | # for example, is a problem that a customer cannot solve and requires your
9 | # attention).
10 | #
11 | # If a record of the transaction was stored in Recurly, it will be
12 | # accessible via {Error#transaction}.
13 | #
14 | # @example
15 | # begin
16 | # subscription.save!
17 | # rescue Recurly::Resource::Invalid => e
18 | # # Display e.record.errors...
19 | # rescue Recurly::Transaction::DeclinedError => e
20 | # # Display e.message and/or subscription (and associated) errors...
21 | # rescue Recurly::Transaction::RetryableError => e
22 | # # You should be able to attempt to save this again later.
23 | # rescue Recurly::Transaction::Error => e
24 | # # Alert yourself of the issue (i.e., log e.transaction).
25 | # # Display a generic error message.
26 | # end
27 | class Error < API::UnprocessableEntity
28 | # @return [Transaction] The transaction as returned (or updated) by
29 | # Recurly.
30 | attr_reader :transaction
31 |
32 | def initialize request, response, transaction
33 | super request, response
34 | update_transaction transaction
35 | end
36 |
37 | # @return [String] A customer-friendly error message.
38 | def to_s
39 | xml.text '/errors/transaction_error/customer_message'
40 | end
41 |
42 | # @return [String] The transaction error code.
43 | def transaction_error_code
44 | xml.text '/errors/transaction_error/error_code'
45 | end
46 |
47 | private
48 |
49 | def update_transaction transaction
50 | return unless transaction_xml = xml['/errors/transaction']
51 |
52 | @transaction = transaction
53 | transaction = Transaction.from_xml transaction_xml
54 | if @transaction.nil?
55 | @transaction = transaction
56 | else
57 | @transaction.instance_variable_get(:@attributes).update(
58 | transaction.attributes
59 | )
60 | end
61 | @transaction.persist!
62 | end
63 | end
64 |
65 | # Raised when a transaction fails for a temporary reason. The transaction
66 | # should be retried later.
67 | class RetryableError < Error
68 | end
69 |
70 | # Raised when a transaction fails due to a misconfiguration, e.g. if the
71 | # gateway hasn't been configured.
72 | class ConfigurationError < Error
73 | end
74 |
75 | # Raised when a transaction fails because the billing information was
76 | # invalid.
77 | class DeclinedError < Error
78 | end
79 |
80 | # Raised when the gateway believes this transaction to be a duplicate.
81 | class DuplicateError < DeclinedError
82 | end
83 |
84 | class << Error
85 | CATEGORY_MAP = Hash.new DeclinedError
86 | CATEGORY_MAP.update(
87 | 'communication' => RetryableError,
88 | 'configuration' => ConfigurationError,
89 | 'duplicate' => DuplicateError
90 | )
91 |
92 | def validate! exception, transaction
93 | return unless exception.is_a? API::UnprocessableEntity
94 |
95 | category = exception.send(:xml).text(
96 | '/errors/transaction_error/error_category'
97 | ) and raise CATEGORY_MAP[category].new(
98 | exception.request, exception.response, transaction
99 | )
100 | end
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/lib/recurly/api/net_http_adapter.rb:
--------------------------------------------------------------------------------
1 | require 'cgi'
2 | require 'net/https'
3 |
4 | module Recurly
5 | class API
6 | module Net
7 | module HTTPAdapter
8 | # A hash of Net::HTTP settings configured before the request.
9 | #
10 | # @return [Hash]
11 | def net_http
12 | @net_http ||= {}
13 | end
14 |
15 | # Used to store any Net::HTTP settings.
16 | #
17 | # @example
18 | # Recurly::API.net_http = {
19 | # :verify_mode => OpenSSL::SSL::VERIFY_PEER,
20 | # :ca_path => "/etc/ssl/certs",
21 | # :ca_file => "/opt/local/share/curl/curl-ca-bundle.crt"
22 | # }
23 | attr_writer :net_http
24 |
25 | private
26 |
27 | METHODS = {
28 | :head => ::Net::HTTP::Head,
29 | :get => ::Net::HTTP::Get,
30 | :post => ::Net::HTTP::Post,
31 | :put => ::Net::HTTP::Put,
32 | :delete => ::Net::HTTP::Delete
33 | }
34 |
35 | def request method, uri, options = {}
36 | head = headers.dup
37 | head.update options[:head] if options[:head]
38 | head.delete_if { |_, value| value.nil? }
39 | uri = base_uri + URI.escape(uri)
40 | if options[:params] && !options[:params].empty?
41 | pairs = options[:params].map { |key, value|
42 | "#{CGI.escape key.to_s}=#{CGI.escape value.to_s}"
43 | }
44 | uri += "?#{pairs.join '&'}"
45 | end
46 | request = METHODS[method].new uri.request_uri, head
47 | request.basic_auth(*[Recurly.api_key, nil].flatten[0, 2])
48 | if options[:body]
49 | request['Content-Type'] = content_type
50 | request.body = options[:body]
51 | end
52 | if options[:etag]
53 | request['If-None-Match'] = options[:etag]
54 | end
55 | if options[:format]
56 | request['Accept'] = FORMATS[options[:format]]
57 | end
58 | if options[:locale]
59 | request['Accept-Language'] = options[:locale]
60 | end
61 | http = ::Net::HTTP.new uri.host, uri.port
62 | http.use_ssl = uri.scheme == 'https'
63 | net_http.each_pair { |key, value| http.send "#{key}=", value }
64 |
65 | if Recurly.logger
66 | Recurly.log :info, "===> %s %s" % [request.method, uri]
67 | headers = request.to_hash
68 | headers['authorization'] &&= ['Basic [FILTERED]']
69 | Recurly.log :debug, headers.inspect
70 | if request.body && !request.body.empty?
71 | Recurly.log :debug, XML.filter(request.body)
72 | end
73 | start_time = Time.now
74 | end
75 |
76 | response = http.start { http.request request }
77 | code = response.code.to_i
78 |
79 | if Recurly.logger
80 | latency = (Time.now - start_time) * 1_000
81 | level = case code
82 | when 200...300 then :info
83 | when 300...400 then :warn
84 | when 400...500 then :error
85 | else :fatal
86 | end
87 | Recurly.log level, "<=== %d %s (%.1fms)" % [
88 | code,
89 | response.class.name[9, response.class.name.length].gsub(
90 | /([a-z])([A-Z])/, '\1 \2'
91 | ),
92 | latency
93 | ]
94 | Recurly.log :debug, response.to_hash.inspect
95 | Recurly.log :debug, response.body if response.body
96 | end
97 |
98 | case code
99 | when 200...300 then response
100 | else raise ERRORS[code].new request, response
101 | end
102 | end
103 | end
104 | end
105 |
106 | extend Net::HTTPAdapter
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/spec/recurly/js_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Recurly.js do
4 | let(:js) { Recurly.js }
5 |
6 | describe "private_key" do
7 | it "must be assignable" do
8 | js.private_key = 'a_private_key'
9 | js.private_key.must_equal 'a_private_key'
10 | end
11 |
12 | it "must raise an exception when not set" do
13 | if js.instance_variable_defined? :@private_key
14 | js.send :remove_instance_variable, :@private_key
15 | end
16 | proc { Recurly.js.private_key }.must_raise ConfigurationError
17 | end
18 |
19 | it "must raise an exception when set to nil" do
20 | Recurly.js.private_key = nil
21 | proc { Recurly.js.private_key }.must_raise ConfigurationError
22 | end
23 | end
24 |
25 | describe ".sign" do
26 | let(:sign) { js.method :sign }
27 | let(:private_key) { '0123456789abcdef0123456789abcdef' }
28 | let(:timestamp) { 1329942896 }
29 |
30 | class MockTime
31 | class << self
32 | attr_accessor :now
33 | end
34 | end
35 |
36 | class MockBase64
37 | class << self
38 | def encode64(*) 'unique' end
39 | end
40 | end
41 |
42 | before do
43 | js.private_key = '0123456789abcdef0123456789abcdef'
44 | @time = Time
45 | Object.const_set :Time, MockTime
46 | Time.now = @time.at timestamp
47 | @base64 = Base64
48 | Object.const_set :Base64, MockBase64
49 | end
50 |
51 | after do
52 | Object.const_set :Time, @time
53 | Object.const_set :Base64, @base64
54 | end
55 |
56 | it "must sign transaction request" do
57 | Recurly.js.sign(
58 | 'account' => { 'account_code' => '123' },
59 | 'transaction' => {
60 | 'amount_in_cents' => 5000,
61 | 'currency' => 'USD'
62 | }
63 | ).must_equal < { 'account_code' => '123' },
76 | 'subscription' => {
77 | 'plan_code' => 'gold'
78 | }
79 | ).must_equal < '123')
91 | Recurly.js.sign(billing_info).must_equal < 'gold'
101 | account = Account.new :account_code => '123'
102 | Recurly.js.sign(subscription, account).must_equal < 50_00
114 | transaction.persist!
115 | account = Account.new :account_code => '123'
116 | Recurly.js.sign(transaction, account).must_equal <
6 |
7 |
8 | created-invoice
9 | open
10 | 1000
11 |
12 |
13 | 300
14 | 0
15 | 300
16 | USD
17 | 2011-04-30T08:00:00Z
18 |
19 |
20 |
21 |
22 | charge
23 | invoiced
24 | Special charge
25 | one_time
26 | 100
27 | 1
28 | 0
29 | 0
30 | 100
31 | USD
32 | false
33 |
34 |
35 | 2011-04-30T08:00:00Z
36 |
37 |
38 |
39 |
40 |
41 | transaction
42 | purchase
43 | 300
44 | 0
45 | USD
46 | success
47 |
48 | false
49 | true
50 | true
51 | true
52 |
53 |
54 |
55 |
56 | 2011-04-30T08:00:00Z
57 |
58 |
59 | abcdef1234567890
60 | Lucille
61 | Bluth
62 |
63 | lucille@bluth-company.com
64 |
65 | George
66 | Bluth
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | Visa
76 | 2014
77 | 1
78 | 411111
79 | 1111
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/lib/recurly/money.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | # Represents a collection of currencies (in cents).
3 | class Money
4 | # @return A money object representing multiple currencies (in cents).
5 | # @param currencies [Hash] A hash of currency codes and amounts.
6 | # @example
7 | # # 12 United States dollars.
8 | # Recurly::Money.new :USD => 12_00
9 | #
10 | # # $9.99 (or €6.99).
11 | # Recurly::Money.new :USD => 9_99, :EUR => 6_99
12 | #
13 | # # Using a default currency.
14 | # Recurly.default_currency = 'USD'
15 | # Recurly::Money.new(49_00) # => #
16 | def initialize currencies = {}, parent = nil, attribute = nil
17 | @currencies = {}
18 | @parent = parent
19 | @attribute = attribute
20 |
21 | if currencies.respond_to? :each_pair
22 | currencies.each_pair { |key, value| @currencies[key.to_s] = value }
23 | elsif Recurly.default_currency
24 | self[Recurly.default_currency] = currencies
25 | else
26 | message = 'expected a Hash'
27 | message << ' or Numeric' if Recurly.default_currency
28 | message << " but received #{currencies.class}"
29 | raise ArgumentError, message
30 | end
31 | end
32 |
33 | def [] code
34 | currencies[code.to_s]
35 | end
36 |
37 | def []= code, amount
38 | currencies[code.to_s] = amount
39 | @parent.send "#@attribute=", dup if @parent
40 | amount
41 | end
42 |
43 | # @return [Hash] A hash of currency codes to amounts.
44 | def to_hash
45 | currencies.dup
46 | end
47 |
48 | # @return [true, false] Whether or not the currency is equal to another
49 | # instance.
50 | # @param other [Money]
51 | def eql? other
52 | other.respond_to?(:currencies) && currencies.eql?(other.currencies)
53 | end
54 |
55 | # @return [Integer] Unique identifier.
56 | # @see Hash#hash
57 | def hash
58 | currencies.hash
59 | end
60 |
61 | # Implemented so that solitary currencies can be compared and sorted.
62 | #
63 | # @return [-1, 0, 1]
64 | # @param other [Money]
65 | # @example
66 | # [Recurly::Money.new(2_00), Recurly::Money.new(1_00)].sort
67 | # # => [#, #]
68 | # @see Hash#<=>
69 | def <=> other
70 | if currencies.keys.length == 1 && other.currencies.length == 1
71 | if currencies.keys == other.currencies.keys
72 | return currencies.values.first <=> other.currencies.values.first
73 | end
74 | end
75 |
76 | currencies <=> other.currencies
77 | end
78 |
79 | # @return [true, false]
80 | # @see Object#respond_to?
81 | def respond_to? method_name, include_private = false
82 | super || currencies.respond_to?(method_name, include_private)
83 | end
84 |
85 | # @return [String]
86 | def inspect
87 | string = "#<#{self.class}"
88 | if currencies.any?
89 | string << " %s" % currencies.keys.sort.map { |code|
90 | value = currencies[code].to_s
91 | value.gsub!(/^(\d)$/, '0_0\1')
92 | value.gsub!(/^(\d{2})$/, '0_\1')
93 | value.gsub!(/(\d)(\d{2})$/, '\1_\2')
94 | value.gsub!(/(\d)(?=(\d\d\d)+(?!\d))/, '\1_')
95 | "#{code}: #{value}"
96 | }.join(', ')
97 | end
98 | string << '>'
99 | end
100 | alias to_s inspect
101 |
102 | protected
103 |
104 | attr_reader :currencies
105 |
106 | private
107 |
108 | def method_missing name, *args, &block
109 | if currencies.respond_to? name
110 | return currencies.send name, *args, &block
111 | elsif c = currencies[Recurly.default_currency] and c.respond_to? name
112 | if currencies.keys.length > 1
113 | raise TypeError, "can't convert multicurrency into Integer"
114 | else
115 | return c.send name, *args, &block
116 | end
117 | end
118 |
119 | super
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/spec/recurly/account_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Account do
4 | describe "instance methods" do
5 | let(:account) {
6 | stub_api_request :get, 'accounts/abcdef1234567890', 'accounts/show-200'
7 | Account.find 'abcdef1234567890'
8 | }
9 |
10 | describe "#invoice!" do
11 | it "must invoice an account if successful" do
12 | stub_api_request(
13 | :post, 'accounts/abcdef1234567890/invoices', 'invoices/create-201'
14 | )
15 | account.invoice!.must_be_instance_of Invoice
16 | end
17 |
18 | it "must raise an exception if unsuccessful" do
19 | stub_api_request(
20 | :post, 'accounts/abcdef1234567890/invoices', 'invoices/create-422'
21 | )
22 | error = proc { account.invoice! }.must_raise Resource::Invalid
23 | error.message.must_equal 'No charges to invoice'
24 | end
25 | end
26 | end
27 |
28 | describe ".find" do
29 | it "must return an account when available" do
30 | stub_api_request :get, 'accounts/abcdef1234567890', 'accounts/show-200'
31 | account = Account.find 'abcdef1234567890'
32 | account.must_be_instance_of Account
33 | account.account_code.must_equal 'abcdef1234567890'
34 | account.username.must_equal 'shmohawk58'
35 | account.email.must_equal 'larry.david@example.com'
36 | account.first_name.must_equal 'Larry'
37 | account.last_name.must_equal 'David'
38 | account.accept_language.must_equal 'en-US'
39 | end
40 |
41 | it "must raise an exception when unavailable" do
42 | stub_api_request :get, 'accounts/abcdef1234567890', 'accounts/show-404'
43 | proc { Account.find 'abcdef1234567890' }.must_raise Resource::NotFound
44 | end
45 | end
46 |
47 | describe ".paginate" do
48 | it "must return a pager" do
49 | stub_api_request :get, 'accounts', 'accounts/index-200'
50 | pager = Account.paginate
51 | pager.must_be_instance_of Resource::Pager
52 | end
53 | end
54 |
55 | describe "#save" do
56 | before do
57 | @account = Account.new :account_code => 'code'
58 | end
59 |
60 | it "must return true when new and valid" do
61 | stub_api_request :post, 'accounts', 'accounts/create-201'
62 | @account.save.must_equal true
63 | end
64 |
65 | it "must return false when new and invalid" do
66 | stub_api_request :post, 'accounts', 'accounts/create-422'
67 | @account.save.must_equal false
68 | @account.errors[:email].wont_be_nil
69 | end
70 |
71 | it "must embed provided billing info" do
72 | @account.billing_info = { :credit_card_number => 4111111111111111 }
73 | @account.to_xml.must_equal <\
75 | code\
76 | \
77 | 4111111111111111\
78 | \
79 |
80 | XML
81 | end
82 |
83 | it 'handle empty values for embedded billing info' do
84 | stub_api_request :post, 'accounts', 'accounts/create-201'
85 | @account.billing_info = { :number => '', :verification_value => '' }
86 | @account.save.must_equal true
87 | end
88 |
89 | describe "persisted accounts" do
90 | before do
91 | @account.persist!
92 | @account.username = "heisenberg"
93 | end
94 |
95 | it "must return true when updating and valid" do
96 | stub_api_request :put, 'accounts/code', 'accounts/update-200'
97 | @account.save.must_equal true
98 | end
99 |
100 | it "must return false when updating and invalid" do
101 | stub_api_request :put, 'accounts/code', 'accounts/update-422'
102 | @account.save.must_equal false
103 | @account.errors[:email].wont_be_nil
104 | end
105 | end
106 | end
107 |
108 | describe "#to_xml" do
109 | it "must serialize" do
110 | account = Account.new :username => 'importantbreakfast'
111 | account.to_xml.must_equal(
112 | 'importantbreakfast'
113 | )
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/README.markdown:
--------------------------------------------------------------------------------
1 | # Recurly [](http://travis-ci.org/recurly/recurly-client-ruby)
2 |
3 |
4 |
5 | [Recurly](http://recurly.com/)'s Ruby client library is an interface to its
6 | [REST API](http://docs.recurly.com/api/basics).
7 |
8 |
9 | ## Installation
10 |
11 | Recurly is packaged as a Ruby gem. We recommend you install it with
12 | [Bundler](http://gembundler.com/) by adding the following line to your Gemfile:
13 |
14 | ``` ruby
15 | gem 'recurly', '~> 2.1.8'
16 | ```
17 |
18 | Recurly will automatically use [Nokogiri](http://nokogiri.org/) (for a nice
19 | speed boost) if it's available and loaded in your app's environment.
20 |
21 |
22 | ## Configuration
23 |
24 | If you're using Rails, you can generate an initializer with the following
25 | command:
26 |
27 | ``` bash
28 | $ rails g recurly:config
29 | ```
30 |
31 | If you're not using Rails, use the following template:
32 |
33 | ``` ruby
34 | Recurly.subdomain = ENV['RECURLY_SUBDOMAIN']
35 | Recurly.api_key = ENV['RECURLY_API_KEY']
36 | Recurly.js.private_key = ENV['RECURLY_JS_PRIVATE_KEY']
37 | ```
38 |
39 | Configure the client library with
40 | [your API credentials](https://app.recurly.com/go/developer/api_access).
41 |
42 | The default currency is USD. To override with a different code:
43 |
44 | ``` ruby
45 | Recurly.default_currency = 'EUR' # Assign nil to disable the default entirely.
46 | ```
47 |
48 | The client library currently uses a Net::HTTP adapter. If you need to
49 | configure the settings passed to Net::HTTP (e.g., an SSL certificates path),
50 | make sure you assign them before you make any requests:
51 |
52 | ``` ruby
53 | Recurly::API.net_http = {
54 | :ca_path => "/etc/ssl/certs"
55 | }
56 | ```
57 |
58 |
59 | ## Usage
60 |
61 | Instructions and examples are available on
62 | [Recurly's documentation site](http://docs.recurly.com/api/basics).
63 |
64 | Recurly's gem API is available
65 | [here](http://rubydoc.info/gems/recurly/2.1.5/frames).
66 |
67 |
68 | ## Contributing
69 |
70 | Developing for the Recurly gem is easy with [Bundler](http://gembundler.com/).
71 |
72 | Fork and clone the repository, `cd` into the directory, and, with a Ruby of
73 | your choice (1.8.7 is supported, but we suggest 1.9.2 or greater), set up your
74 | environment.
75 |
76 | If you don't have Bundler installed, install it with the following command:
77 |
78 | ``` bash
79 | $ [sudo] gem install bundler
80 | ```
81 |
82 | And bundle:
83 |
84 | ``` bash
85 | $ bundle --path=vendor/bundle
86 | ```
87 |
88 | You should now be able to run the test suite with Rake:
89 |
90 | ``` bash
91 | $ bundle exec rake
92 | ```
93 |
94 | To run the suite using Nokogiri:
95 |
96 | ``` bash
97 | $ XML=nokogiri bundle exec rake
98 | ```
99 |
100 | If you plan on submitting a patch, please write tests for it (we use
101 | [MiniTest::Spec](http://bfts.rubyforge.org/minitest/MiniTest/Expectations.html)).
102 |
103 | If everything looks good, submit a pull request on GitHub and we'll bring in
104 | your changes.
105 |
106 |
107 | ## License
108 |
109 | (The MIT License.)
110 |
111 | © 2009–2013 Recurly Inc.
112 |
113 | Permission is hereby granted, free of charge, to any person obtaining a copy
114 | of this software and associated documentation files (the "Software"), to deal
115 | in the Software without restriction, including without limitation the rights
116 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
117 | copies of the Software, and to permit persons to whom the Software is
118 | furnished to do so, subject to the following conditions:
119 |
120 | The above copyright notice and this permission notice shall be included in all
121 | copies or substantial portions of the Software.
122 |
123 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
124 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
125 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
126 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
127 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
128 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
129 | SOFTWARE.
130 |
--------------------------------------------------------------------------------
/spec/recurly/resource/pager_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Resource::Pager do
4 | let(:pager) { Resource::Pager.new resource }
5 | let(:resource) { Class.new(Resource) { def self.name() 'Resource' end } }
6 |
7 | describe "an instance" do
8 | it "must instantiate" do
9 | pager.must_be_instance_of Resource::Pager
10 | end
11 |
12 | it "must return an enumerator when no block is given" do
13 | pager.each.must_be_instance_of(
14 | defined?(Enumerator) ? Enumerator : Enumerable::Enumerator
15 | )
16 | end
17 |
18 | it "must iterate over a collection" do
19 | stub_api_request(:get, 'resources') { XML[200][:index] }
20 | records = pager.each { |r|
21 | r.must_be_instance_of pager.resource_class
22 | }
23 | records.must_be_instance_of Array
24 | records.size.must_equal 2
25 | end
26 |
27 | it "must iterate across pages" do
28 | stub_api_request(:get, 'resources') { XML[200][:index] }
29 | stub_api_request(:get, 'resources?cursor=1234567890&per_page=2') {
30 | XML[200][:index]
31 | }
32 | pager.find_each { |r| r.must_be_instance_of pager.resource_class }
33 | end
34 |
35 | describe "#count" do
36 | it "must fetch the count via HEAD" do
37 | stub_api_request(:head, 'resources') { XML[200][:index] }
38 | pager.count.must_equal 3
39 | end
40 |
41 | it "must not fetch the count when already loaded" do
42 | stub_api_request(:get, 'resources') { XML[200][:index] }
43 | pager.reload
44 | pager.count.must_equal 3
45 | end
46 | end
47 |
48 | describe "#links" do
49 | describe "page 1/2" do
50 | before do
51 | stub_api_request(:get, 'resources') { XML[200][:index][0] }
52 | pager.load!
53 | end
54 |
55 | it "must be tracked" do
56 | pager.links.wont_be_empty
57 | end
58 |
59 | describe "valid paging" do
60 | it "must go to the next page" do
61 | stub_api_request(:get, 'resources?per_page=2&cursor=1234567890') {
62 | XML[200][:index][1]
63 | }
64 | pager.next.wont_be_empty
65 | end
66 | end
67 |
68 | it "must return nil if the page is not available" do
69 | pager.start.must_be_nil
70 | end
71 | end
72 |
73 | describe "page 2/2" do
74 | before do
75 | stub_api_request(:get, 'resources') { XML[200][:index][1] }
76 | pager.load!
77 | end
78 |
79 | describe "valid paging" do
80 | it "must go to the first page" do
81 | stub_api_request(:get, 'resources?per_page=2') {
82 | XML[200][:index][0]
83 | }
84 | pager.start.wont_be_empty
85 | end
86 | end
87 |
88 | it "must return nil if the page is not available" do
89 | pager.next.must_be_nil
90 | end
91 | end
92 | end
93 |
94 | describe "building records" do
95 | let(:path) { 'resources/1/children' }
96 |
97 | before do
98 | pager.instance_variable_set :@uri, path
99 | end
100 |
101 | describe "#new" do
102 | it "must instantiate a new record with scoped path set" do
103 | child = pager.new :name => 'pagerino'
104 | child.must_be_instance_of resource
105 | stub_api_request(:post, path) { XML[201] }
106 | child.save
107 | end
108 | end
109 |
110 | describe "#create" do
111 | it "must create a new record through scoped path" do
112 | stub_api_request(:post, path) { XML[201] }
113 | child = pager.create :name => 'pagerina'
114 | child.must_be_instance_of resource
115 | end
116 | end
117 |
118 | describe "#create!" do
119 | it "must create a new record through scoped path" do
120 | stub_api_request(:post, path) { XML[201] }
121 | child = pager.create! :name => 'pagerello'
122 | child.must_be_instance_of resource
123 | end
124 |
125 | it "must raise an exception when invalid" do
126 | stub_api_request(:post, path) { XML[422] }
127 | proc {
128 | pager.create! :name => 'pagerella'
129 | }.must_raise Resource::Invalid
130 | end
131 | end
132 | end
133 |
134 | describe "#method_missing" do
135 | it "must build scopes" do
136 | resource.scope :active, :active => true
137 | active = pager.active
138 | active.must_be_instance_of Resource::Pager
139 | stub_api_request(:get, 'resources?active=true') { <
143 | XML
144 | active.load!
145 | end
146 | end
147 | end
148 | end
149 |
--------------------------------------------------------------------------------
/lib/recurly/subscription.rb:
--------------------------------------------------------------------------------
1 | module Recurly
2 | class Subscription < Resource
3 | autoload :AddOns, 'recurly/subscription/add_ons'
4 |
5 | # @macro [attach] scope
6 | # @scope class
7 | # @return [Pager] A pager that yields +$1+ subscriptions.
8 | scope :active, :state => :active
9 | scope :canceled, :state => :canceled
10 | scope :expired, :state => :expired
11 | scope :future, :state => :future
12 | # @return [Pager] A pager that yields subscriptions in
13 | # trials.
14 | scope :in_trial, :state => :in_trial
15 | # @return [Pager] A pager that yields active, canceled, and
16 | # future subscriptions.
17 | scope :live, :state => :live
18 | scope :past_due, :state => :past_due
19 |
20 | # @return [Account]
21 | belongs_to :account
22 | # @return [Plan]
23 | belongs_to :plan
24 |
25 | define_attribute_methods %w(
26 | uuid
27 | state
28 | unit_amount_in_cents
29 | currency
30 | quantity
31 | activated_at
32 | canceled_at
33 | expires_at
34 | current_period_started_at
35 | current_period_ends_at
36 | trial_started_at
37 | trial_ends_at
38 | pending_subscription
39 | subscription_add_ons
40 | coupon_code
41 | total_billing_cycles
42 | )
43 | alias to_param uuid
44 |
45 | # @return [Subscription] A new subscription.
46 | def initialize attributes = {}
47 | super({ :currency => Recurly.default_currency }.merge attributes)
48 | end
49 |
50 | # Assign a Plan resource (rather than a plan code).
51 | #
52 | # @param plan [Plan]
53 | def plan= plan
54 | self.plan_code = (plan.plan_code if plan.respond_to? :plan_code)
55 | attributes[:plan] = plan
56 | end
57 |
58 | def plan_code
59 | self[:plan_code] ||= (plan.plan_code if plan.respond_to? :plan_code)
60 | end
61 |
62 | def plan_code= plan_code
63 | self[:plan_code] = plan_code
64 | end
65 |
66 | # Assign a Coupon resource (rather than a coupon code).
67 | #
68 | # @param coupon [Coupon]
69 | def coupon= coupon
70 | self.coupon_code = (
71 | coupon.coupon_code if coupon.respond_to? :coupon_code
72 | )
73 | attributes[:coupon] = coupon
74 | end
75 |
76 | # @return [AddOns]
77 | def subscription_add_ons
78 | self[:subscription_add_ons] ||= AddOns.new self, super
79 | end
80 | alias add_ons subscription_add_ons
81 |
82 | # Assign an array of subscription add-ons.
83 | def subscription_add_ons= subscription_add_ons
84 | super AddOns.new self, subscription_add_ons
85 | end
86 | alias add_ons= subscription_add_ons=
87 |
88 | # Cancel a subscription so that it will not renew.
89 | #
90 | # @return [true, false] +true+ when successful, +false+ when unable to
91 | # (e.g., the subscription is not active).
92 | # @example
93 | # account = Account.find account_code
94 | # subscription = account.subscriptions.first
95 | # subscription.cancel # => true
96 | def cancel
97 | return false unless self[:cancel]
98 | reload self[:cancel].call
99 | true
100 | end
101 |
102 | # An array of acceptable refund types.
103 | REFUND_TYPES = ['none', 'full', 'partial'].freeze
104 |
105 | # Immediately terminate a subscription (with optional refund).
106 | #
107 | # @return [true, false] +true+ when successful, +false+ when unable to
108 | # (e.g., the subscription is not active).
109 | # @param refund_type [:none, :full, :partial] :none terminates the
110 | # subscription with no refund (the default), :full refunds the
111 | # subscription in full, and :partial refunds the subscription in
112 | # part.
113 | # @raise [ArgumentError] Invalid +refund_type+.
114 | # @example
115 | # account = Account.find account_code
116 | # subscription = account.subscriptions.first
117 | # subscription.terminate(:partial) # => true
118 | def terminate refund_type = :none
119 | return false unless self[:terminate]
120 | unless REFUND_TYPES.include? refund_type.to_s
121 | raise ArgumentError, "refund must be one of: #{REFUND_TYPES.join ', '}"
122 | end
123 | reload self[:terminate].call(:params => { :refund => refund_type })
124 | true
125 | end
126 | alias destroy terminate
127 |
128 | # Reactivate a subscription.
129 | #
130 | # @return [true, false] +true+ when successful, +false+ when unable to
131 | # (e.g., the subscription is already active), and may raise an exception
132 | # if the reactivation fails.
133 | def reactivate
134 | return false unless self[:reactivate]
135 | reload self[:reactivate].call
136 | true
137 | end
138 |
139 | # Postpone a subscription's renewal date.
140 | #
141 | # @return [true, false] +true+ when successful, +false+ when unable to
142 | # (e.g., the subscription is not active).
143 | # @param next_renewal_date [Time] when the subscription should renew.
144 | def postpone next_renewal_date
145 | return false unless self[:postpone]
146 | reload self[:postpone].call(
147 | :params => { :next_renewal_date => next_renewal_date }
148 | )
149 | true
150 | end
151 |
152 | def signable_attributes
153 | super.merge :plan_code => plan_code
154 | end
155 | end
156 | end
157 |
--------------------------------------------------------------------------------
/lib/recurly/api/errors.rb:
--------------------------------------------------------------------------------
1 | require 'cgi'
2 |
3 | module Recurly
4 | class API
5 | # The superclass to all errors that occur when making an API request.
6 | class ResponseError < Error
7 | attr_reader :request
8 | attr_reader :response
9 |
10 | def initialize request, response
11 | @request, @response = request, response
12 | end
13 |
14 | def code
15 | response.code.to_i if response
16 | end
17 |
18 | def to_s
19 | if description
20 | return CGI.unescapeHTML [description, details].compact.join(' ')
21 | end
22 |
23 | return super unless code
24 | "%d %s (%s %s)" % [
25 | code, http_error, request.method, API.base_uri + request.path
26 | ]
27 | end
28 |
29 | def symbol
30 | xml and xml.root and xml.text '/error/symbol'
31 | end
32 |
33 | def description
34 | xml and xml.root and xml.text '/error/description'
35 | end
36 |
37 | def details
38 | xml and xml.root and xml.text '/error/details'
39 | end
40 |
41 | private
42 |
43 | def http_error
44 | Helper.demodulize self.class.name.gsub(/([a-z])([A-Z])/, '\1 \2')
45 | end
46 |
47 | def xml
48 | return @xml if defined? @xml
49 | @xml = (XML.new(response.body) if response && !response.body.empty?)
50 | end
51 | end
52 |
53 | # === 3xx Redirection
54 | #
55 | # Not an error, per se, but should result in one in the normal course of
56 | # API interaction.
57 | class Redirection < ResponseError
58 | end
59 |
60 | # === 304 Not Modified
61 | #
62 | # Catchably raised when a request is made with an ETag.
63 | class NotModified < ResponseError
64 | end
65 |
66 | # === 4xx Client Error
67 | #
68 | # The superclass to all client errors (responses with status code 4xx).
69 | class ClientError < ResponseError
70 | end
71 |
72 | # === 400 Bad Request
73 | #
74 | # The request was invalid or could not be understood by the server.
75 | # Resubmitting the request will likely result in the same error.
76 | class BadRequest < ClientError
77 | end
78 |
79 | # === 401 Unauthorized
80 | #
81 | # The API key is missing or invalid for the given request.
82 | class Unauthorized < ClientError
83 | def description
84 | response.body.strip
85 | end
86 | end
87 |
88 | # === 402 Payment Required
89 | #
90 | # Your Recurly account is in production mode but is not in good standing.
91 | # Please pay any outstanding invoices.
92 | class PaymentRequired < ClientError
93 | end
94 |
95 | # === 403 Forbidden
96 | #
97 | # The login is attempting to perform an action it does not have privileges
98 | # to access. The login credentials are correct.
99 | class Forbidden < ClientError
100 | end
101 |
102 | # === 404 Not Found
103 | #
104 | # The resource was not found. This may be returned if the given account
105 | # code or subscription plan does not exist. The response body will explain
106 | # which resource was not found.
107 | class NotFound < ClientError
108 | end
109 |
110 | # === 405 Method Not Allowed
111 | #
112 | # A method was attempted where it is not allowed.
113 | #
114 | # If this is raised, there may be a bug with the client library or with
115 | # the server. Please contact support@recurly.com or
116 | # {file a bug}[https://github.com/recurly/recurly-client-ruby/issues].
117 | class MethodNotAllowed < ClientError
118 | end
119 |
120 | # === 406 Not Acceptable
121 | #
122 | # The request content type was not acceptable.
123 | #
124 | # If this is raised, there may be a bug with the client library or with
125 | # the server. Please contact support@recurly.com or
126 | # {file a bug}[https://github.com/recurly/recurly-client-ruby/issues].
127 | class NotAcceptable < ClientError
128 | end
129 |
130 | # === 412 Precondition Failed
131 | #
132 | # The request was unsuccessful because a condition was not met. For
133 | # example, this message may be returned if you attempt to cancel a
134 | # subscription for an account that has no subscription.
135 | class PreconditionFailed < ClientError
136 | end
137 |
138 | # === 415 Unsupported Media Type
139 | #
140 | # The request body was not recognized as XML.
141 | #
142 | # If this is raised, there may be a bug with the client library or with
143 | # the server. Please contact support@recurly.com or
144 | # {file a bug}[https://github.com/recurly/recurly-client-ruby/issues].
145 | class UnsupportedMediaType < ClientError
146 | end
147 |
148 | # === 422 Unprocessable Entity
149 | #
150 | # Could not process a POST or PUT request because the request is invalid.
151 | # See the response body for more details.
152 | class UnprocessableEntity < ClientError
153 | end
154 |
155 | # === 5xx Server Error
156 | #
157 | # The superclass to all server errors (responses with status code 5xx).
158 | class ServerError < ResponseError
159 | end
160 |
161 | # === 500 Internal Server Error
162 | #
163 | # The server encountered an error while processing your request and failed.
164 | class InternalServerError < ServerError
165 | end
166 |
167 | # === 502 Gateway Error
168 | #
169 | # The load balancer or web server had trouble connecting to the Recurly.
170 | # Please try the request again.
171 | class GatewayError < ServerError
172 | end
173 |
174 | # === 503 Service Unavailable
175 | #
176 | # The service is temporarily unavailable. Please try the request again.
177 | class ServiceUnavailable < ServerError
178 | end
179 |
180 | # Error mapping by status code.
181 | ERRORS = Hash.new { |hash, code|
182 | unless hash.key? code
183 | case code
184 | when 400...500 then ClientError
185 | when 500...600 then ServerError
186 | else ResponseError
187 | end
188 | end
189 | }.update(
190 | 304 => NotModified,
191 | 400 => BadRequest,
192 | 401 => Unauthorized,
193 | 402 => PaymentRequired,
194 | 403 => Forbidden,
195 | 404 => NotFound,
196 | 406 => NotAcceptable,
197 | 412 => PreconditionFailed,
198 | 415 => UnsupportedMediaType,
199 | 422 => UnprocessableEntity,
200 | 500 => InternalServerError,
201 | 502 => GatewayError,
202 | 503 => ServiceUnavailable
203 | ).freeze
204 | end
205 | end
206 |
--------------------------------------------------------------------------------
/spec/recurly/subscription_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Subscription do
4 | describe "attributes" do
5 | subject { Subscription }
6 |
7 | it "has all expected attributes" do
8 | expected_attributes = %w{ uuid
9 | state
10 | unit_amount_in_cents
11 | currency
12 | quantity
13 | activated_at
14 | canceled_at
15 | expires_at
16 | current_period_started_at
17 | current_period_ends_at
18 | trial_started_at
19 | trial_ends_at
20 | pending_subscription
21 | subscription_add_ons
22 | coupon_code
23 | total_billing_cycles}
24 |
25 | subject.attribute_names.sort.must_equal expected_attributes.sort
26 | end
27 | end
28 |
29 | describe "add-ons" do
30 | it "must assign via symbol array" do
31 | subscription = Subscription.new :add_ons => [:trial]
32 | subscription.add_ons.must_equal(
33 | Subscription::AddOns.new(subscription, [:trial])
34 | )
35 | end
36 |
37 | it "must assign via hash array" do
38 | subscription = Subscription.new :add_ons => [
39 | {:add_on_code => "trial", :quantity => 2}, {:add_on_code => "trial2"}
40 | ]
41 | subscription.add_ons.to_a.must_equal([
42 | SubscriptionAddOn.new("add_on_code"=>"trial", "quantity"=>2),
43 | SubscriptionAddOn.new("add_on_code"=>"trial2")
44 | ])
45 | end
46 |
47 | it "must assign track multiple addons" do
48 | subscription = Subscription.new :add_ons => [:trial, :trial]
49 | subscription.add_ons.to_a.must_equal([
50 | SubscriptionAddOn.new({"add_on_code"=>"trial", "quantity"=>2})
51 | ])
52 | end
53 |
54 | it "must assign via hash array" do
55 | subscription = Subscription.new :add_ons => [
56 | {:add_on_code => "trial", :quantity => 2}, {:add_on_code => "trial2"}
57 | ]
58 | subscription.add_ons.to_a.must_equal([
59 | SubscriptionAddOn.new("add_on_code"=>"trial", "quantity"=>2),
60 | SubscriptionAddOn.new("add_on_code"=>"trial2")
61 | ])
62 | end
63 |
64 | it "must assign track multiple addons" do
65 | subscription = Subscription.new :add_ons => [:trial, :trial]
66 | subscription.add_ons.to_a.must_equal([
67 | SubscriptionAddOn.new("add_on_code"=>"trial", "quantity"=>2)
68 | ])
69 | end
70 |
71 | it "must serialize" do
72 | subscription = Subscription.new
73 | subscription.add_ons << :trial
74 | subscription.to_xml.must_equal <\
76 | USD\
77 | \
78 | trial\
79 | \
80 |
81 | XML
82 | end
83 |
84 | it "must deserialize" do
85 | xml = <\
87 | \
88 | 200\
89 | \
90 | USD\
91 | \
92 | trial2
93 | trial2\
94 | \
95 |
96 | XML
97 | subscription = Subscription.from_xml xml
98 | subscription.pending_subscription.must_be_instance_of Subscription
99 | subscription.add_ons.to_a.must_equal([
100 | SubscriptionAddOn.new("add_on_code"=>"trial", "quantity"=>2),
101 | SubscriptionAddOn.new("add_on_code"=>"trial2")
102 | ])
103 | end
104 | end
105 |
106 | describe "active and inactive" do
107 | let(:active) {
108 | stub_api_request :get, 'subscriptions/active', 'subscriptions/show-200'
109 | Subscription.find 'active'
110 | }
111 |
112 | let(:inactive) {
113 | stub_api_request(
114 | :get, 'subscriptions/inactive', 'subscriptions/show-200-inactive'
115 | )
116 | Subscription.find 'inactive'
117 | }
118 |
119 | describe "#cancel" do
120 | it "must cancel an active subscription" do
121 | stub_api_request(
122 | :put,
123 | 'subscriptions/abcdef1234567890/cancel',
124 | 'subscriptions/show-200'
125 | )
126 | active.cancel.must_equal true
127 | end
128 |
129 | it "won't cancel an inactive subscription" do
130 | inactive.cancel.must_equal false
131 | end
132 | end
133 |
134 | describe "#terminate" do
135 | it "must fully refund a subscription" do
136 | stub_api_request(
137 | :put,
138 | 'subscriptions/abcdef1234567890/terminate?refund=full',
139 | 'subscriptions/show-200'
140 | )
141 | active.terminate(:full).must_equal true
142 | end
143 |
144 | it "won't fully refund an inactive subscription" do
145 | inactive.terminate(:full).must_equal false
146 | end
147 |
148 | it "must partially refund a subscription" do
149 | stub_api_request(
150 | :put,
151 | 'subscriptions/abcdef1234567890/terminate?refund=partial',
152 | 'subscriptions/show-200'
153 | )
154 | active.terminate(:partial).must_equal true
155 | end
156 |
157 | it "won't partially refund an inactive subscription" do
158 | inactive.terminate(:partial).must_equal false
159 | end
160 |
161 | it "must terminate a subscription with no refund" do
162 | stub_api_request(
163 | :put,
164 | 'subscriptions/abcdef1234567890/terminate?refund=none',
165 | 'subscriptions/show-200'
166 | )
167 | active.terminate.must_equal true
168 | end
169 | end
170 |
171 | describe "#reactivate" do
172 | it "must reactivate an inactive subscription" do
173 | stub_api_request(
174 | :put,
175 | 'subscriptions/abcdef1234567890/reactivate',
176 | 'subscriptions/show-200'
177 | )
178 | inactive.reactivate.must_equal true
179 | end
180 |
181 | it "won't reactivate an active subscription" do
182 | active.reactivate.must_equal false
183 | end
184 | end
185 |
186 | describe "plan assignment" do
187 | it "must use the assigned plan code" do
188 | active.plan_code = 'new_plan'
189 | active.plan_code.must_equal 'new_plan'
190 | end
191 | end
192 | end
193 | end
194 |
--------------------------------------------------------------------------------
/lib/recurly/resource/pager.rb:
--------------------------------------------------------------------------------
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 Resource#{has_many_association}
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 # => #
13 | #
14 | # Recurly::Account.find_each { |a| p a }
15 | # @example Through an resource instance:
16 | # account.transactions
17 | # # => #
18 | #
19 | # account.transactions.new(attributes) # or #create, or #create!
20 | # # => #
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 :etag 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 previous page.
93 | def prev
94 | load_from links['prev'], nil if links.key? 'prev'
95 | end
96 |
97 | # @return [Array, nil] Refreshes the pager's collection of records with
98 | # the first page.
99 | def start
100 | load_from links['start'], nil if links.key? 'start'
101 | end
102 |
103 | # @return [Array, nil] Load (or reload) the pager's collection from the
104 | # original, supplied options.
105 | def load!
106 | load_from uri, @options
107 | end
108 | alias reload load!
109 |
110 | # @return [Pager] Duplicates the pager, updating it with the options
111 | # supplied. Useful for resource scopes.
112 | # @see #initialize
113 | # @example
114 | # Recurly::Account.active.paginate :per_page => 20
115 | def paginate options = {}
116 | dup.instance_eval {
117 | @collection = @count = @etag = nil
118 | @options = @options.merge options
119 | self
120 | }
121 | end
122 | alias scoped paginate
123 | alias where paginate
124 |
125 | def all options = {}
126 | paginate(options).to_a
127 | end
128 |
129 | # Instantiates a new record in the scope of the pager.
130 | #
131 | # @return [Resource] A new record.
132 | # @example
133 | # account = Recurly::Account.find 'schrader'
134 | # subscription = account.subscriptions.new attributes
135 | # @see Resource.new
136 | def new attributes = {}
137 | record = resource_class.send(:new, attributes) { |r|
138 | r.attributes[@parent.class.member_name] ||= @parent if @parent
139 | r.uri = uri
140 | }
141 | yield record if block_given?
142 | record
143 | end
144 |
145 | # Instantiates and saves a record in the scope of the pager.
146 | #
147 | # @return [Resource] The record.
148 | # @raise [Transaction::Error] A monetary transaction failed.
149 | # @example
150 | # account = Recurly::Account.find 'schrader'
151 | # subscription = account.subscriptions.create attributes
152 | # @see Resource.create
153 | def create attributes = {}
154 | new(attributes) { |record| record.save }
155 | end
156 |
157 | # Instantiates and saves a record in the scope of the pager.
158 | #
159 | # @return [Resource] The saved record.
160 | # @raise [Invalid] The record is invalid.
161 | # @raise [Transaction::Error] A monetary transaction failed.
162 | # @example
163 | # account = Recurly::Account.find 'schrader'
164 | # subscription = account.subscriptions.create! attributes
165 | # @see Resource.create!
166 | def create! attributes = {}
167 | new(attributes) { |record| record.save! }
168 | end
169 |
170 | def find uuid
171 | if resource_class.respond_to? :find
172 | raise NoMethodError,
173 | "#find must be called on #{resource_class} directly"
174 | end
175 |
176 | resource_class.from_response API.get("#{uri}/#{uuid}")
177 | end
178 |
179 | # @return [true, false]
180 | # @see Object#respond_to?
181 | def respond_to? method_name, include_private = false
182 | super || [].respond_to?(method_name, include_private)
183 | end
184 |
185 | private
186 |
187 | def load_from uri, params
188 | options = {}
189 | options[:head] = { 'If-None-Match' => etag } if etag
190 | response = API.get uri, params, options
191 |
192 | @etag = response['ETag']
193 | @count = response['X-Records'].to_i
194 | @links = {}
195 | if links = response['Link']
196 | links.scan(/<([^>]+)>; rel="([^"]+)"/).each do |link, rel|
197 | @links[rel] = link.freeze
198 | end
199 | end
200 | @links.freeze
201 |
202 | @collection = []
203 | document = XML.new response.body
204 | document.each_element(resource_class.member_name) do |el|
205 | record = resource_class.from_xml el
206 | record.attributes[@parent.class.member_name] = @parent if @parent
207 | @collection << record
208 | end
209 | @collection.freeze
210 | rescue API::NotModified
211 | @collection and @collection or raise
212 | end
213 |
214 | def method_missing name, *args, &block
215 | scope = resource_class.scopes[name] and return paginate scope
216 |
217 | if [].respond_to? name
218 | load! unless @collection
219 | return @collection.send name, *args, &block
220 | end
221 |
222 | super
223 | end
224 | end
225 | end
226 | end
227 |
--------------------------------------------------------------------------------
/spec/recurly/resource_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Resource do
4 | let(:resource) {
5 | Class.new Resource do
6 | def self.name
7 | 'Resource'
8 | end
9 |
10 | def to_param
11 | self[:uuid]
12 | end
13 | end
14 | }
15 |
16 | describe "names and paths" do
17 | it "must provide default names" do
18 | resource.resource_name.must_equal "Resource"
19 | resource.collection_name.must_equal "resources"
20 | resource.member_name.must_equal "resource"
21 | end
22 |
23 | it "must provide relative paths" do
24 | resource.collection_path.must_equal "resources"
25 | resource.member_path(nil).must_equal "resources"
26 | resource.member_path(1).must_equal "resources/1"
27 | end
28 | end
29 |
30 | describe "class methods" do
31 | describe ".define_attribute_methods" do
32 | it "must define attribute methods" do
33 | resource.define_attribute_methods(names = %w(charisma endurance))
34 | resource.attribute_names.must_equal names
35 | record = resource.new
36 | resource.attribute_names.each do |name|
37 | record.send(name).must_be_nil
38 | record.send("#{name}?").must_equal false
39 | record.send("#{name}=", amount = 100)
40 | record.send(name).must_equal amount
41 | record.send("#{name}?").must_equal true
42 | end
43 | end
44 | end
45 |
46 | describe ".paginate" do
47 | it "must return a pager" do
48 | pager = resource.paginate
49 | pager.must_be_instance_of Resource::Pager
50 | pager.resource_class.must_equal resource
51 | end
52 | end
53 |
54 | describe ".scopes" do
55 | it "must return a hash of scopes" do
56 | resource.scopes.must_be_instance_of Hash
57 | end
58 | end
59 |
60 | describe ".scope" do
61 | it "must define a named scope with options" do
62 | resource.scope :active, :active => true
63 | resource.scopes[:active].must_equal :active => true
64 | pager = resource.active
65 | pager.must_be_instance_of Resource::Pager
66 | stub_api_request(:get, 'resources?active=true') { <
70 | XML
71 | pager.load!
72 | end
73 | end
74 |
75 | describe ".find" do
76 | it "must return a record that exists" do
77 | stub_api_request(:get, 'resources/spock') { XML[200][:show] }
78 | resource.find(:spock).must_be_instance_of resource
79 | end
80 |
81 | it "must raise an error if no record is found" do
82 | stub_api_request(:get, 'resources/khan') { XML[404] }
83 | proc { resource.find :khan }.must_raise Resource::NotFound
84 | end
85 | end
86 |
87 | describe ".create" do
88 | it "must return a saved record when valid" do
89 | stub_api_request(:post, 'resources') { XML[201] }
90 | record = resource.create
91 | record.must_be_instance_of resource
92 | end
93 |
94 | it "must return an unsaved record when invalid" do
95 | stub_api_request(:post, 'resources') { XML[422] }
96 | record = resource.create
97 | record.must_be_instance_of resource
98 | end
99 | end
100 |
101 | describe ".create!" do
102 | it "must return a saved record when valid" do
103 | stub_api_request(:post, 'resources') { XML[201] }
104 | record = resource.create!
105 | record.must_be_instance_of resource
106 | end
107 |
108 | it "must raise an exception when invalid" do
109 | stub_api_request(:post, 'resources') { XML[422] }
110 | proc { resource.create! }.must_raise Resource::Invalid
111 | end
112 | end
113 |
114 | describe ".from_xml" do
115 | it "must deserialize based on type" do
116 | begin
117 | record = resource.from_xml <
119 |
120 | Arrested Development
121 | true
122 | false
123 | 2006-02-10
124 | 2003-11-02T00:00:00+00:00
125 | 3.3
126 | 53
127 |
128 |
129 | 1
130 | 2
131 | 3
132 |
133 |
134 | 4
135 |
136 |
137 |
138 |
139 | XML
140 | record.instance_variable_defined?(:@href).must_equal true
141 | record.uri.must_equal "https://api.recurly.com/v2/resources/1"
142 | record[:name].must_equal 'Arrested Development'
143 | record[:was_hilarious].must_equal true
144 | record[:is_in_production].must_equal false
145 | record[:canceled_on].must_equal Date.new(2006, 2, 10)
146 | record[:first_aired_at].must_equal DateTime.new(2003, 11, 2)
147 | record[:finale_ratings].must_equal 3.3
148 | record[:number_of_episodes].must_equal 53
149 | 3.times { |n| record[:seasons][n].must_be_kind_of Integer }
150 | record[:never_gonna_happen]['season'].must_be_kind_of Integer
151 | stub_api_request(:put, 'resources/1/renew') { "HTTP/1.1 200\n" }
152 | record[:renew].call
153 | stub_api_request(:delete, 'resources/1/cancel') { "HTTP/1.1 422\n" }
154 | proc { record[:cancel].call }.must_raise API::UnprocessableEntity
155 | end
156 | end
157 | end
158 |
159 | describe ".associations" do
160 | it "must be empty without any associations defined" do
161 | resource.associations[:has_many].must_be_empty
162 | resource.associations[:has_one].must_be_empty
163 | end
164 | end
165 |
166 | describe ".has_many" do
167 | before do
168 | Recurly.const_set :Reason, Class.new(Resource)
169 | resource.has_many :reasons
170 | end
171 |
172 | after do
173 | Recurly.send :remove_const, :Reason
174 | end
175 |
176 | it "must define an association" do
177 | resource.associations[:has_many].must_include 'reasons'
178 | resource.reflect_on_association(:reasons).must_equal :has_many
179 | end
180 |
181 | it "must return a pager for fresh records" do
182 | resource.new.reasons.must_be_kind_of Enumerable
183 | end
184 | end
185 |
186 | describe ".has_one and .belongs_to" do
187 | before do
188 | Recurly.const_set :Day, Class.new(Resource)
189 | resource.has_one :day
190 | Day.belongs_to :resource
191 | end
192 |
193 | after do
194 | Recurly.send :remove_const, :Day
195 | end
196 |
197 | it "must define an association" do
198 | resource.associations[:has_one].must_include 'day'
199 | resource.reflect_on_association(:day).must_equal :has_one
200 | Day.associations[:belongs_to].must_include 'resource'
201 | Day.reflect_on_association(:resource).must_equal :belongs_to
202 | end
203 |
204 | it "must return nil for new records" do
205 | resource.new.day.must_be_nil
206 | end
207 |
208 | it "must lazily fetch a record and assign a relation" do
209 | stub_api_request(:get, 'resources/1') { <
213 |
214 | Stephen
215 |
216 |
217 | XML
218 | record = resource.find "1"
219 | stub_api_request(:get, 'resources/1/day') { <
223 |
224 |
225 | Monday
226 |
227 | XML
228 | record.day.must_be_instance_of Day
229 | record.day.resource.must_equal record
230 | end
231 | end
232 |
233 | describe ".has_one, readonly => false" do
234 | before do
235 | Recurly.const_set :Day, Class.new(Resource)
236 | resource.has_one :day, :readonly => false
237 | @record = resource.new
238 | end
239 |
240 | after do
241 | Recurly.send :remove_const, :Day
242 | end
243 |
244 | it "must assign relation from a Hash" do
245 | @record.day = {}
246 | @record.day.must_be_kind_of Day
247 | end
248 | it "must assign relation from an instance of the associated class" do
249 | @record.day = Day.new
250 | @record.day.must_be_kind_of Day
251 | end
252 | it "assigning relation from another class must raise an exception" do
253 | proc { @record.day = Class }.must_raise ArgumentError
254 | end
255 | end
256 |
257 | describe "#initialize" do
258 | let(:record) { resource.new :name => 'Gesundheit' }
259 |
260 | it "must instantiate" do
261 | record.must_be_instance_of resource
262 | end
263 |
264 | it "must return a new record" do
265 | record.new_record?.must_equal true
266 | record.persisted?.must_equal false
267 | record.destroyed?.must_equal false
268 | end
269 |
270 | it "must assign attributes" do
271 | record[:name].must_equal 'Gesundheit'
272 | end
273 | end
274 | end
275 |
276 | describe "instance methods" do
277 | let(:record) { resource.new }
278 |
279 | before do
280 | resource.define_attribute_methods [:name]
281 | end
282 |
283 | describe "#reload" do
284 | it "must raise an exception for new records" do
285 | proc { record.reload }.must_raise Resource::NotFound
286 | end
287 |
288 | it "must reload attributes for persistent records" do
289 | record[:uuid] = 'neo'
290 | stub_api_request(:get, 'resources/neo') { <
294 | neo
295 | The Matrix
296 |
297 | XML
298 | record.reload
299 | record[:name].must_equal 'The Matrix'
300 | end
301 | end
302 |
303 | describe "#persisted?" do
304 | it "must return false for new records" do
305 | record.persisted?.must_equal false
306 | end
307 |
308 | it "must return true for persisted records" do
309 | record.instance_eval { @new_record = @destroyed = false }
310 | record.persisted?.must_equal true
311 | end
312 |
313 | it "must return false for destroyed records" do
314 | record.instance_eval { @new_record, @destroyed = false, true }
315 | record.persisted?.must_equal false
316 | end
317 | end
318 |
319 | describe "#read_attribute" do
320 | it "must read with string or symbol" do
321 | record.name = 'Hi'
322 | record[:name].must_equal 'Hi'
323 | record['name'].must_equal 'Hi'
324 | end
325 | end
326 |
327 | describe "#write_attribute" do
328 | it "must write with string or symbol" do
329 | record[:name] = 'What?'
330 | record.name.must_equal 'What?'
331 | record['name'] = 'Who?'
332 | record.name.must_equal 'Who?'
333 | end
334 |
335 | it "must track changed attributes" do
336 | record[:uuid] = 1
337 | record[:name] = 'William'
338 | record.persist!
339 |
340 | record.changed_attributes.must_be_empty
341 | record.name = 'Wendy'
342 | record.changed_attributes.key?('name').must_equal true
343 | record.changed.must_include 'name'
344 | record.changes.must_equal 'name' => %w(William Wendy)
345 | record.name_change.must_equal %w(William Wendy)
346 | record.name_changed?.must_equal true
347 | record.name_was.must_equal 'William'
348 | record.persist! true
349 | record.changed_attributes.must_be_empty
350 | record.previous_changes.must_equal 'name' => %w(William Wendy)
351 | record.name_previously_changed?.must_equal true
352 | record.name_previously_was.must_equal 'William'
353 | end
354 | end
355 |
356 | describe "#to_xml" do
357 | before do
358 | record[:uuid] = 'Eminem'
359 | record.persist!
360 | record[:name] = 'Slim Shady'
361 | end
362 |
363 | it "must only show deltas" do
364 | record.to_xml.must_equal "Slim Shady"
365 | end
366 | end
367 |
368 | describe "saving" do
369 | it "must post new records" do
370 | stub_api_request(:post, 'resources') { XML[201] }
371 | record.save!
372 | end
373 |
374 | it "must put persisted records" do
375 | def record.to_param() 1 end
376 | record.persist!
377 | record.name = 'Persistent Little Bug'
378 | stub_api_request(:put, 'resources/1') { XML[200][:update] }
379 | record.save!
380 | end
381 |
382 | describe "invalid records" do
383 | before do
384 | stub_api_request(:post, 'resources') { XML[422] }
385 | end
386 |
387 | it "#save must return false and assign errors" do
388 | record.errors.empty?.must_equal true
389 | record.save.must_equal false
390 | record.errors[:name].wont_be_nil
391 | end
392 |
393 | it "#save! must raise an exception" do
394 | proc { record.save! }.must_raise Resource::Invalid
395 | end
396 | end
397 | end
398 |
399 | describe "#errors" do
400 | it "must return a Hash for errors" do
401 | record.errors.must_be_kind_of Hash
402 | end
403 | end
404 |
405 | describe "#persist!" do
406 | before do
407 | record[:uuid] = 'snowflake'
408 | end
409 |
410 | it "must convert new records to persisted" do
411 | record.new_record?.must_equal true
412 | record.persisted?.must_equal false
413 | record.persist!.must_equal true
414 | record.new_record?.must_equal false
415 | record.persisted?.must_equal true
416 | end
417 |
418 | it "must clear previous changes" do
419 | record.name = 'Name'
420 | record.persist!.must_equal true
421 | record.changed_attributes.must_be_empty
422 | end
423 | end
424 |
425 | describe "#uri" do
426 | it "must return nil for a resource where persisted is false" do
427 | record.uri.must_be_nil
428 | end
429 |
430 | it "must return a URI for a resource where persisted is false" do
431 | def record.to_param() 1 end
432 | record.persist!
433 | record.uri.must_equal 'https://api.recurly.com/v2/resources/1'
434 | end
435 | end
436 |
437 | describe "#destroy" do
438 | it "must return false if a record does not persist" do
439 | record.destroy.must_equal false
440 | end
441 |
442 | it "must destroy a record that persists" do
443 | def record.to_param() 1 end
444 | record.persist!
445 | stub_api_request(:delete, 'resources/1') { XML[200][:destroy] }
446 | record.destroy.must_equal true
447 | end
448 |
449 | it "must raise an error if a persisted record is not found" do
450 | def record.to_param() 1 end
451 | record.persist!
452 | stub_api_request(:delete, 'resources/1') { XML[404] }
453 | proc { record.destroy }.must_raise Resource::NotFound
454 | end
455 | end
456 | end
457 | end
458 |
--------------------------------------------------------------------------------
/spec/fixtures/accounts/index-200.xml:
--------------------------------------------------------------------------------
1 | HTTP/1.1 200 OK
2 | Content-Type: application/xml; charset=utf-8
3 | X-Records: 123
4 | Link: ; rel="start", ; rel="next"
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | api100
16 | api100_u
17 | api100@example.com
18 | api100_fn
19 | api100_ln
20 | api100_cn
21 | api100_al
22 | 18d935f06b0547ddad8cdf2490ac802e
23 | 2011-04-30T12:00:00Z
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | api99
33 | api99_u
34 | api99@example.com
35 | api99_fn
36 | api99_ln
37 | api99_cn
38 | api99_al
39 | 18d935f06b0547ddad8cdf2490ac802e
40 | 2011-04-30T12:00:00Z
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | api98
50 | api98_u
51 | api98@example.com
52 | api98_fn
53 | api98_ln
54 | api98_cn
55 | api98_al
56 | 18d935f06b0547ddad8cdf2490ac802e
57 | 2011-04-30T12:00:00Z
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | api97
67 | api97_u
68 | api97@example.com
69 | api97_fn
70 | api97_ln
71 | api97_cn
72 | api97_al
73 | 18d935f06b0547ddad8cdf2490ac802e
74 | 2011-04-30T12:00:00Z
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | api96
84 | api96_u
85 | api96@example.com
86 | api96_fn
87 | api96_ln
88 | api96_cn
89 | api96_al
90 | 18d935f06b0547ddad8cdf2490ac802e
91 | 2011-04-30T12:00:00Z
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | api95
101 | api95_u
102 | api95@example.com
103 | api95_fn
104 | api95_ln
105 | api95_cn
106 | api95_al
107 | 18d935f06b0547ddad8cdf2490ac802e
108 | 2011-04-30T12:00:00Z
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | api94
118 | api94_u
119 | api94@example.com
120 | api94_fn
121 | api94_ln
122 | api94_cn
123 | api94_al
124 | 18d935f06b0547ddad8cdf2490ac802e
125 | 2011-04-30T12:00:00Z
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 | api93
135 | api93_u
136 | api93@example.com
137 | api93_fn
138 | api93_ln
139 | api93_cn
140 | api93_al
141 | 18d935f06b0547ddad8cdf2490ac802e
142 | 2011-04-30T12:00:00Z
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 | api92
152 | api92_u
153 | api92@example.com
154 | api92_fn
155 | api92_ln
156 | api92_cn
157 | api92_al
158 | 18d935f06b0547ddad8cdf2490ac802e
159 | 2011-04-30T12:00:00Z
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | api91
169 | api91_u
170 | api91@example.com
171 | api91_fn
172 | api91_ln
173 | api91_cn
174 | api91_al
175 | 18d935f06b0547ddad8cdf2490ac802e
176 | 2011-04-30T12:00:00Z
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 | api90
186 | api90_u
187 | api90@example.com
188 | api90_fn
189 | api90_ln
190 | api90_cn
191 | api90_al
192 | 18d935f06b0547ddad8cdf2490ac802e
193 | 2011-04-30T12:00:00Z
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 | api89
203 | api89_u
204 | api89@example.com
205 | api89_fn
206 | api89_ln
207 | api89_cn
208 | api89_al
209 | 18d935f06b0547ddad8cdf2490ac802e
210 | 2011-04-30T12:00:00Z
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 | api88
220 | api88_u
221 | api88@example.com
222 | api88_fn
223 | api88_ln
224 | api88_cn
225 | api88_al
226 | 18d935f06b0547ddad8cdf2490ac802e
227 | 2011-04-30T12:00:00Z
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 | api87
237 | api87_u
238 | api87@example.com
239 | api87_fn
240 | api87_ln
241 | api87_cn
242 | api87_al
243 | 18d935f06b0547ddad8cdf2490ac802e
244 | 2011-04-30T12:00:00Z
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 | api86
254 | api86_u
255 | api86@example.com
256 | api86_fn
257 | api86_ln
258 | api86_cn
259 | api86_al
260 | 18d935f06b0547ddad8cdf2490ac802e
261 | 2011-04-30T12:00:00Z
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 | api85
271 | api85_u
272 | api85@example.com
273 | api85_fn
274 | api85_ln
275 | api85_cn
276 | api85_al
277 | 18d935f06b0547ddad8cdf2490ac802e
278 | 2011-04-30T12:00:00Z
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 | api84
288 | api84_u
289 | api84@example.com
290 | api84_fn
291 | api84_ln
292 | api84_cn
293 | api84_al
294 | 18d935f06b0547ddad8cdf2490ac802e
295 | 2011-04-30T12:00:00Z
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 | api83
305 | api83_u
306 | api83@example.com
307 | api83_fn
308 | api83_ln
309 | api83_cn
310 | api83_al
311 | 18d935f06b0547ddad8cdf2490ac802e
312 | 2011-04-30T12:00:00Z
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 | api82
322 | api82_u
323 | api82@example.com
324 | api82_fn
325 | api82_ln
326 | api82_cn
327 | api82_al
328 | 18d935f06b0547ddad8cdf2490ac802e
329 | 2011-04-30T12:00:00Z
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 | api81
339 | api81_u
340 | api81@example.com
341 | api81_fn
342 | api81_ln
343 | api81_cn
344 | api81_al
345 | 18d935f06b0547ddad8cdf2490ac802e
346 | 2011-04-30T12:00:00Z
347 |
348 |
349 |
--------------------------------------------------------------------------------
/lib/recurly/resource.rb:
--------------------------------------------------------------------------------
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 | # # => #
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 create! 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 | # # => #
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 :Errors, 'recurly/resource/errors'
117 | autoload :Pager, 'recurly/resource/pager'
118 |
119 | # Raised when a record cannot be found.
120 | #
121 | # @example
122 | # begin
123 | # Recurly::Account.find 'tortuga'
124 | # rescue Recurly::Resource::NotFound => e
125 | # e.message # => "Can't find Account with account_code = tortuga"
126 | # end
127 | class NotFound < API::NotFound
128 | def initialize message
129 | set_message message
130 | end
131 | end
132 |
133 | # Raised when a record is invalid.
134 | #
135 | # @example
136 | # begin
137 | # Recurly::Account.create! :first_name => "Flynn"
138 | # rescue Recurly::Resource::Invalid => e
139 | # e.record.errors # => errors: {"account_code"=>["can't be blank"]}>
140 | # end
141 | class Invalid < API::UnprocessableEntity
142 | # @return [Resource, nil] The invalid record.
143 | attr_reader :record
144 |
145 | def initialize record_or_message
146 | set_message case record_or_message
147 | when Resource
148 | @record = record_or_message
149 | record_or_message.errors.map { |k, v| "#{k} #{v * ', '}" }.join '; '
150 | else
151 | record_or_message
152 | end
153 | end
154 | end
155 |
156 | class << self
157 | # @return [String] The demodulized name of the resource class.
158 | # @example
159 | # Recurly::Account.name # => "Account"
160 | def resource_name
161 | Helper.demodulize name
162 | end
163 |
164 | # @return [String] The underscored, pluralized name of the resource
165 | # class.
166 | # @example
167 | # Recurly::Account.collection_name # => "accounts"
168 | def collection_name
169 | Helper.pluralize Helper.underscore(resource_name)
170 | end
171 | alias collection_path collection_name
172 |
173 | # @return [String] The underscored name of the resource class.
174 | # @example
175 | # Recurly::Account.member_name # => "account"
176 | def member_name
177 | Helper.underscore resource_name
178 | end
179 |
180 | # @return [String] The relative path to a resource's identifier from the
181 | # API's base URI.
182 | # @param uuid [String, nil]
183 | # @example
184 | # Recurly::Account.member_path "code" # => "accounts/code"
185 | # Recurly::Account.member_path nil # => "accounts"
186 | def member_path uuid
187 | [collection_path, uuid].compact.join '/'
188 | end
189 |
190 | # @return [Array] Per attribute, defines readers, writers, boolean and
191 | # change-tracking methods.
192 | # @param attribute_names [Array] An array of attribute names.
193 | # @example
194 | # class Account < Resource
195 | # define_attribute_methods [:name]
196 | # end
197 | #
198 | # a = Account.new
199 | # a.name? # => false
200 | # a.name # => nil
201 | # a.name = "Stephen"
202 | # a.name? # => true
203 | # a.name # => "Stephen"
204 | # a.name_changed? # => true
205 | # a.name_was # => nil
206 | # a.name_change # => [nil, "Stephen"]
207 | def define_attribute_methods attribute_names
208 | @attribute_names = attribute_names.map! { |m| m.to_s }.sort!.freeze
209 | remove_const :AttributeMethods if constants.include? :AttributeMethods
210 | include const_set :AttributeMethods, Module.new {
211 | attribute_names.each do |name|
212 | define_method(name) { self[name] } # Get.
213 | define_method("#{name}=") { |value| self[name] = value } # Set.
214 | define_method("#{name}?") { !!self[name] } # Present.
215 | define_method("#{name}_change") { changes[name] } # Dirt...
216 | define_method("#{name}_changed?") { changed_attributes.key? name }
217 | define_method("#{name}_was") { changed_attributes[name] }
218 | define_method("#{name}_previously_changed?") {
219 | previous_changes.key? name
220 | }
221 | define_method("#{name}_previously_was") {
222 | previous_changes[name].first if previous_changes.key? name
223 | }
224 | end
225 | }
226 | end
227 |
228 | # @return [Array, nil] The list of attribute names defined for the
229 | # resource class.
230 | attr_reader :attribute_names
231 |
232 | # @return [Pager] A pager with an iterable collection of records
233 | # @param options [Hash] A hash of pagination options
234 | # @option options [Integer] :per_page The number of records returned per
235 | # page
236 | # @option options [DateTime, Time, Integer] :cursor A timestamp that the
237 | # pager will skim back to and return records created before it
238 | # @option options [String] :etag When set, will raise
239 | # {Recurly::API::NotModified} if the pager's loaded page content has
240 | # not changed
241 | # @example Fetch 50 records and iterate over them
242 | # Recurly::Account.paginate(:per_page => 50).each { |a| p a }
243 | # @example Fetch records before January 1, 2011
244 | # Recurly::Account.paginate(:cursor => Time.new(2011, 1, 1))
245 | def paginate options = {}
246 | Pager.new self, options
247 | end
248 | alias scoped paginate
249 | alias where paginate
250 |
251 | def all options = {}
252 | paginate(options).to_a
253 | end
254 |
255 | # @return [Hash] Defined scopes per resource.
256 | def scopes
257 | @scopes ||= Recurly::Helper.hash_with_indifferent_read_access
258 | end
259 |
260 | # @return [Module] Module of scopes methods.
261 | def scopes_helper
262 | @scopes_helper ||= Module.new.tap { |helper| extend helper }
263 | end
264 |
265 | # Defines a new resource scope.
266 | #
267 | # @return [Proc]
268 | # @param [Symbol] name the scope name
269 | # @param [Hash] params the scope params
270 | def scope name, params = {}
271 | scopes[name = name.to_s] = params
272 | scopes_helper.send(:define_method, name) { paginate scopes[name] }
273 | end
274 |
275 | # Iterates through every record by automatically paging.
276 | #
277 | # @return [nil]
278 | # @param [Integer] per_page The number of records returned per request.
279 | # @yield [record]
280 | # @see Pager#find_each
281 | # @example
282 | # Recurly::Account.find_each { |a| p a }
283 | def find_each per_page = 50
284 | paginate(:per_page => per_page).find_each(&Proc.new)
285 | end
286 |
287 | # @return [Integer] The total record count of the resource in question.
288 | # @see Pager#count
289 | # @example
290 | # Recurly::Account.count # => 42
291 | def count
292 | paginate.count
293 | end
294 |
295 | # @api internal
296 | # @return [Resource, nil]
297 | def first
298 | paginate(:per_page => 1).first
299 | end
300 |
301 | # @return [Resource] A record matching the designated unique identifier.
302 | # @param [String] uuid The unique identifier of the resource to be
303 | # retrieved.
304 | # @param [Hash] options A hash of options.
305 | # @option options [String] :etag When set, will raise {API::NotModified}
306 | # if the record content has not changed.
307 | # @raise [Error] If the resource has no identifier (and thus cannot be
308 | # retrieved).
309 | # @raise [NotFound] If no resource can be found for the supplied
310 | # identifier (or the supplied identifier is +nil+).
311 | # @raise [API::NotModified] If the :etag option is set and
312 | # matches the server's.
313 | # @example
314 | # Recurly::Account.find "heisenberg"
315 | # # => #
316 | def find uuid, options = {}
317 | if uuid.nil?
318 | # Should we raise an ArgumentError, instead?
319 | raise NotFound, "can't find a record with nil identifier"
320 | end
321 |
322 | uri = uuid =~ /^http/ ? uuid : member_path(uuid)
323 | begin
324 | from_response API.get(uri, {}, 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 | case response['Content-Type']
356 | when %r{application/pdf}
357 | response.body
358 | else # when %r{application/xml}
359 | record = from_xml response.body
360 | record.instance_eval { @etag, @response = response['ETag'], response }
361 | record
362 | end
363 | end
364 |
365 | # Instantiates a record from an XML blob: either a String or XML element.
366 | #
367 | # Assuming the record is from an API response, the record is flagged as
368 | # persisted.
369 | #
370 | # @return [Resource]
371 | # @param xml [String, REXML::Element, Nokogiri::XML::Node]
372 | # @see from_response
373 | def from_xml xml
374 | xml = XML.new xml
375 | if self != Resource || xml.name == member_name
376 | record = new
377 | elsif Recurly.const_defined?(
378 | class_name = Helper.classify(xml.name), false
379 | )
380 | klass = Recurly.const_get class_name, false
381 | record = klass.send :new
382 | elsif root = xml.root and root.elements.empty?
383 | return XML.cast root
384 | else
385 | record = {}
386 | end
387 | klass ||= self
388 | associations = klass.associations
389 |
390 | xml.root.attributes.each do |name, value|
391 | record.instance_variable_set "@#{name}", value.to_s
392 | end
393 |
394 | xml.each_element do |el|
395 | if el.name == 'a'
396 | name, uri = el.attribute('name').value, el.attribute('href').value
397 | record[name] = case el.attribute('method').to_s
398 | when 'get', '' then proc { |*opts| API.get uri, {}, *opts }
399 | when 'post' then proc { |*opts| API.post uri, nil, *opts }
400 | when 'put' then proc { |*opts| API.put uri, nil, *opts }
401 | when 'delete' then proc { |*opts| API.delete uri, *opts }
402 | end
403 | next
404 | end
405 |
406 | if el.children.empty? && href = el.attribute('href')
407 | resource_class = Recurly.const_get(
408 | Helper.classify(el.attribute('type') || el.name), false
409 | )
410 | record[el.name] = case el.name
411 | when *associations[:has_many]
412 | Pager.new resource_class, :uri => href.value, :parent => record
413 | when *(associations[:has_one] + associations[:belongs_to])
414 | lambda {
415 | begin
416 | relation = resource_class.from_response API.get(href.value)
417 | relation.attributes[member_name] = record
418 | relation
419 | rescue Recurly::API::NotFound
420 | end
421 | }
422 | end
423 | else
424 | record[el.name] = XML.cast el
425 | end
426 | end
427 |
428 | record.persist! if record.respond_to? :persist!
429 | record
430 | end
431 |
432 | # @return [Hash] A list of association names for the current class.
433 | def associations
434 | @associations ||= {
435 | :has_many => [], :has_one => [], :belongs_to => []
436 | }
437 | end
438 |
439 | def associations_helper
440 | @associations_helper ||= Module.new.tap { |helper| include helper }
441 | end
442 |
443 | # Establishes a has_many association.
444 | #
445 | # @return [Proc, nil]
446 | # @param collection_name [Symbol] Association name.
447 | # @param options [Hash] A hash of association options.
448 | # @option options [true, false] :readonly Don't define a setter.
449 | def has_many collection_name, options = {}
450 | associations[:has_many] << collection_name.to_s
451 | associations_helper.module_eval do
452 | define_method collection_name do
453 | self[collection_name] ||= []
454 | end
455 | if options.key?(:readonly) && options[:readonly] == false
456 | define_method "#{collection_name}=" do |collection|
457 | self[collection_name] = collection
458 | end
459 | end
460 | end
461 | end
462 |
463 | # Establishes a has_one association.
464 | #
465 | # @return [Proc, nil]
466 | # @param member_name [Symbol] Association name.
467 | # @param options [Hash] A hash of association options.
468 | # @option options [true, false] :readonly Don't define a setter.
469 | def has_one member_name, options = {}
470 | associations[:has_one] << member_name.to_s
471 | associations_helper.module_eval do
472 | define_method(member_name) { self[member_name] }
473 | if options.key?(:readonly) && options[:readonly] == false
474 | associated = Recurly.const_get Helper.classify(member_name), false
475 | define_method "#{member_name}=" do |member|
476 | associated_uri = "#{path}/#{member_name}"
477 | self[member_name] = case member
478 | when Hash
479 | associated.send :new, member.merge(:uri => associated_uri)
480 | when associated
481 | member.uri = associated_uri and member
482 | else
483 | raise ArgumentError, "expected #{associated}"
484 | end
485 | end
486 | define_method "build_#{member_name}" do |*args|
487 | attributes = args.shift || {}
488 | self[member_name] = associated.send(
489 | :new, attributes.merge(:uri => "#{path}/#{member_name}")
490 | ).tap { |child| child.attributes[self.class.member_name] = self }
491 | end
492 | define_method "create_#{member_name}" do |*args|
493 | send("build_#{member_name}", *args).tap { |child| child.save }
494 | end
495 | end
496 | end
497 | end
498 |
499 | # Establishes a belongs_to association.
500 | #
501 | # @return [Proc]
502 | def belongs_to parent_name, options = {}
503 | associations[:belongs_to] << parent_name.to_s
504 | associations_helper.module_eval do
505 | define_method(parent_name) { self[parent_name] }
506 | if options.key?(:readonly) && options[:readonly] == false
507 | define_method "#{parent_name}=" do |parent|
508 | self[parent_name] = parent
509 | end
510 | end
511 | end
512 | end
513 |
514 | # @return [:has_many, :has_one, :belongs_to, nil] An association type.
515 | def reflect_on_association name
516 | a = associations.find { |k, v| v.include? name.to_s } and a.first
517 | end
518 |
519 | def embedded! root_index = false
520 | private :initialize
521 | private_class_method(*%w(new create create!))
522 | unless root_index
523 | private_class_method(*%w(all find_each first paginate scoped where))
524 | end
525 | end
526 | end
527 |
528 | # @return [Hash] The raw hash of record attributes.
529 | attr_reader :attributes
530 |
531 | # @return [Net::HTTPResponse, nil] The most recent response object for the
532 | # record (updated during {#save} and {#destroy}).
533 | attr_reader :response
534 |
535 | # @return [String, nil] An ETag for the current record.
536 | attr_reader :etag
537 |
538 | # @return [String, nil] A writer to override the URI the record saves to.
539 | attr_writer :uri
540 |
541 | # @return [Resource] A new resource instance.
542 | # @param attributes [Hash] A hash of attributes.
543 | def initialize attributes = {}
544 | if instance_of? Resource
545 | raise Error,
546 | "#{self.class} is an abstract class and cannot be instantiated"
547 | end
548 |
549 | @attributes, @new_record, @destroyed, @uri, @href = {}, true, false
550 | self.attributes = attributes
551 | yield self if block_given?
552 | end
553 |
554 | def to_param
555 | self[self.class.param_name]
556 | end
557 |
558 | # @return [self] Reloads the record from the server.
559 | def reload response = nil
560 | if response
561 | return if response.body.to_s.length.zero?
562 | fresh = self.class.from_response response
563 | else
564 | fresh = self.class.find(
565 | @href || to_param, :etag => (etag unless changed?)
566 | )
567 | end
568 | fresh and copy_from fresh
569 | persist! true
570 | self
571 | rescue API::NotModified
572 | self
573 | end
574 |
575 | # @return [Hash] Hash of changed attributes.
576 | # @see #changes
577 | def changed_attributes
578 | @changed_attributes ||= {}
579 | end
580 |
581 | # @return [Array] A list of changed attribute keys.
582 | def changed
583 | changed_attributes.keys
584 | end
585 |
586 | # Do any attributes have unsaved changes?
587 | # @return [true, false]
588 | def changed?
589 | !changed_attributes.empty?
590 | end
591 |
592 | # @return [Hash] Map of changed attributes to original value and new value.
593 | def changes
594 | changed_attributes.inject({}) { |changes, (key, original_value)|
595 | changes[key] = [original_value, self[key]] and changes
596 | }
597 | end
598 |
599 | # @return [Hash] Previously-changed attributes.
600 | # @see #changes
601 | def previous_changes
602 | @previous_changes ||= {}
603 | end
604 |
605 | # Is the record new (i.e., not saved on Recurly's servers)?
606 | #
607 | # @return [true, false]
608 | # @see #persisted?
609 | # @see #destroyed?
610 | def new_record?
611 | @new_record
612 | end
613 |
614 | # Has the record been destroyed? (Set +true+ after a successful destroy.)
615 | # @return [true, false]
616 | # @see #new_record?
617 | # @see #persisted?
618 | def destroyed?
619 | @destroyed
620 | end
621 |
622 | # Has the record persisted (i.e., saved on Recurly's servers)?
623 | #
624 | # @return [true, false]
625 | # @see #new_record?
626 | # @see #destroyed?
627 | def persisted?
628 | !(new_record? || destroyed?)
629 | end
630 |
631 | # The value of a specified attribute, lazily fetching any defined
632 | # association.
633 | #
634 | # @param key [Symbol, String] The name of the attribute to be fetched.
635 | # @example
636 | # account.read_attribute :first_name # => "Ted"
637 | # account[:last_name] # => "Beneke"
638 | # @see #write_attribute
639 | def read_attribute key
640 | value = attributes[key = key.to_s]
641 | if value.respond_to?(:call) && self.class.reflect_on_association(key)
642 | value = attributes[key] = value.call
643 | end
644 | value
645 | end
646 | alias [] read_attribute
647 |
648 | # Sets the value of a specified attribute.
649 | #
650 | # @param key [Symbol, String] The name of the attribute to be set.
651 | # @param value [Object] The value the attribute will be set to.
652 | # @example
653 | # account.write_attribute :first_name, 'Gus'
654 | # account[:company_name] = 'Los Pollos Hermanos'
655 | # @see #read_attribute
656 | def write_attribute key, value
657 | if changed_attributes.key?(key = key.to_s)
658 | changed_attributes.delete key if changed_attributes[key] == value
659 | elsif self[key] != value
660 | changed_attributes[key] = self[key]
661 | end
662 |
663 | if self.class.associations.values.flatten.include? key
664 | value = fetch_association key, value
665 | # FIXME: More explicit; less magic.
666 | elsif value && key.end_with?('_in_cents') && !respond_to?(:currency)
667 | value = Money.new value, self, key unless value.is_a? Money
668 | end
669 |
670 | attributes[key] = value
671 | end
672 | alias []= write_attribute
673 |
674 | # Apply a given hash of attributes to a record.
675 | #
676 | # @return [Hash]
677 | # @param attributes [Hash] A hash of attributes.
678 | def attributes= attributes = {}
679 | attributes.each_pair { |k, v|
680 | respond_to?(name = "#{k}=") and send(name, v) or self[k] = v
681 | }
682 | end
683 |
684 | # Serializes the record to XML.
685 | #
686 | # @return [String] An XML string.
687 | # @param options [Hash] A hash of XML options.
688 | # @example
689 | # Recurly::Account.new(:account_code => 'code').to_xml
690 | # # => "code"
691 | def to_xml options = {}
692 | builder = options[:builder] || XML.new("<#{self.class.member_name}/>")
693 | xml_keys.each { |key|
694 | value = respond_to?(key) ? send(key) : self[key]
695 | node = builder.add_element key
696 |
697 | # Duck-typing here is problematic because of ActiveSupport's #to_xml.
698 | case value
699 | when Resource, Subscription::AddOns
700 | value.to_xml options.merge(:builder => node)
701 | when Array
702 | value.each { |e| node.add_element Helper.singularize(key), e }
703 | when Hash, Recurly::Money
704 | value.each_pair { |k, v| node.add_element k.to_s, v }
705 | else
706 | node.text = value
707 | end
708 | }
709 | builder.to_s
710 | end
711 |
712 | # Attempts to save the record, returning the success of the request.
713 | #
714 | # @return [true, false]
715 | # @raise [Transaction::Error] A monetary transaction failed.
716 | # @example
717 | # account = Recurly::Account.new
718 | # account.save # => false
719 | # account.account_code = 'account_code'
720 | # account.save # => true
721 | # @see #save!
722 | def save
723 | if new_record? || changed?
724 | clear_errors
725 | @response = API.send(
726 | persisted? ? :put : :post, path, to_xml(:delta => true)
727 | )
728 | reload response
729 | persist! true
730 | end
731 | true
732 | rescue API::UnprocessableEntity => e
733 | apply_errors e
734 | Transaction::Error.validate! e, (self if is_a? Transaction)
735 | false
736 | end
737 |
738 | # Attempts to save the record, returning +true+ if the record was saved and
739 | # raising {Invalid} otherwise.
740 | #
741 | # @return [true]
742 | # @raise [Invalid] The record was invalid.
743 | # @raise [Transaction::Error] A monetary transaction failed.
744 | # @example
745 | # account = Recurly::Account.new
746 | # account.save! # raises Recurly::Resource::Invalid
747 | # account.account_code = 'account_code'
748 | # account.save! # => true
749 | # @see #save
750 | def save!
751 | save || raise(Invalid.new(self))
752 | end
753 |
754 | # @return [true, false, nil] The validity of the record: +true+ if the
755 | # record was successfully saved (or persisted and unchanged), +false+ if
756 | # the record was not successfully saved, or +nil+ for a record with an
757 | # unknown state (i.e. (i.e. new records that haven't been saved and
758 | # persisted records with changed attributes).
759 | # @example
760 | # account = Recurly::Account.new
761 | # account.valid? # => nil
762 | # account.save # => false
763 | # account.valid? # => false
764 | # account.account_code = 'account_code'
765 | # account.save # => true
766 | # account.valid? # => true
767 | def valid?
768 | return true if persisted? && changed_attributes.empty?
769 | return if errors.empty? && changed_attributes?
770 | errors.empty?
771 | end
772 |
773 | # Update a record with a given hash of attributes.
774 | #
775 | # @return [true, false] The success of the update.
776 | # @param attributes [Hash] A hash of attributes.
777 | # @raise [Transaction::Error] A monetary transaction failed.
778 | # @example
779 | # account = Account.find 'junior'
780 | # account.update_attributes :account_code => 'flynn' # => true
781 | # @see #update_attributes!
782 | def update_attributes attributes = {}
783 | self.attributes = attributes and save
784 | end
785 |
786 | # Update a record with a given hash of attributes.
787 | #
788 | # @return [true] The update was successful.
789 | # @param attributes [Hash] A hash of attributes.
790 | # @raise [Invalid] The record was invalid.
791 | # @raise [Transaction::Error] A monetary transaction failed.
792 | # @example
793 | # account = Account.find 'gale_boetticher'
794 | # account.update_attributes! :account_code => nil # Raises an exception.
795 | # @see #update_attributes
796 | def update_attributes! attributes = {}
797 | self.attributes = attributes and save!
798 | end
799 |
800 | # @return [Hash] A hash with indifferent read access containing any
801 | # validation errors where the key is the attribute name and the value is
802 | # an array of error messages.
803 | # @example
804 | # account.errors # => {"account_code"=>["can't be blank"]}
805 | # account.errors[:account_code] # => ["can't be blank"]
806 | def errors
807 | @errors ||= Errors.new
808 | end
809 |
810 | # Marks a record as persisted, i.e. not a new or deleted record, resetting
811 | # any tracked attribute changes in the process. (This is an internal method
812 | # and should probably not be called unless you know what you're doing.)
813 | #
814 | # @api internal
815 | # @return [true]
816 | def persist! saved = false
817 | @new_record, @uri = false
818 | if changed?
819 | @previous_changes = changes if saved
820 | changed_attributes.clear
821 | end
822 | true
823 | end
824 |
825 | # @return [String, nil] The unique resource identifier (URI) of the record
826 | # (if persisted).
827 | # @example
828 | # Recurly::Account.new(:account_code => "account_code").uri # => nil
829 | # Recurly::Account.find("account_code").uri
830 | # # => "https://api.recurly.com/v2/accounts/account_code"
831 | def uri
832 | @href ||= ((API.base_uri + path).to_s if persisted?)
833 | end
834 |
835 | # Attempts to destroy the record.
836 | #
837 | # @return [true, false] +true+ if successful, +false+ if unable to destroy
838 | # (if the record does not persist on Recurly).
839 | # @raise [NotFound] The record cannot be found.
840 | # @example
841 | # account = Recurly::Account.find account_code
842 | # race_condition = Recurly::Account.find account_code
843 | # account.destroy # => true
844 | # account.destroy # => false (already destroyed)
845 | # race_condition.destroy # raises Recurly::Resource::NotFound
846 | def destroy
847 | return false unless persisted?
848 | @response = API.delete uri
849 | @destroyed = true
850 | rescue API::NotFound => e
851 | raise NotFound, e.description
852 | end
853 |
854 | def signable_attributes
855 | Hash[xml_keys.map { |key| [key, self[key]] }]
856 | end
857 |
858 | def == other
859 | other.is_a?(self.class) && other.to_s == to_s
860 | end
861 |
862 | def marshal_dump
863 | [
864 | @attributes.reject { |k, v| v.is_a? Proc },
865 | @new_record,
866 | @destroyed,
867 | @uri,
868 | @href,
869 | changed_attributes,
870 | previous_changes,
871 | etag,
872 | response
873 | ]
874 | end
875 |
876 | def marshal_load serialization
877 | @attributes,
878 | @new_record,
879 | @destroyed,
880 | @uri,
881 | @href,
882 | @changed_attributes,
883 | @previous_changes,
884 | @response,
885 | @etag = serialization
886 | end
887 |
888 | # @return [String]
889 | def inspect attributes = self.class.attribute_names.to_a
890 | string = "#<#{self.class}"
891 | string << "##@type" if instance_variable_defined? :@type
892 | attributes += %w(errors) if errors.any?
893 | string << " %s" % attributes.map { |k|
894 | "#{k}: #{self.send(k).inspect}"
895 | }.join(', ')
896 | string << '>'
897 | end
898 | alias to_s inspect
899 |
900 | protected
901 |
902 | def path
903 | @href or @uri or if persisted?
904 | self.class.member_path to_param
905 | else
906 | self.class.collection_path
907 | end
908 | end
909 |
910 | def invalid! attribute_path, error
911 | if attribute_path.length == 1
912 | (errors[attribute_path[0]] ||= []) << error
913 | else
914 | child, k, v = attribute_path.shift.scan(/[^\[\]=]+/)
915 | if c = k ? self[child].find { |d| d[k] == v } : self[child]
916 | c.invalid! attribute_path, error
917 | (e = errors[child] ||= []) << 'is invalid' and e.uniq!
918 | end
919 | end
920 | end
921 |
922 | def clear_errors
923 | errors.clear
924 | self.class.associations.each_value do |associations|
925 | associations.each do |association|
926 | next unless respond_to? "#{association}=" # Clear writable only.
927 | [*self[association]].each do |associated|
928 | associated.clear_errors if associated.respond_to? :clear_errors
929 | end
930 | end
931 | end
932 | end
933 |
934 | def copy_from other
935 | other.instance_variables.each do |ivar|
936 | instance_variable_set ivar, other.instance_variable_get(ivar)
937 | end
938 | end
939 |
940 | def apply_errors exception
941 | @response = exception.response
942 | document = XML.new exception.response.body
943 | document.each_element 'error' do |el|
944 | attribute_path = el.attribute('field').value.split '.'
945 | invalid! attribute_path[1, attribute_path.length], el.text
946 | end
947 | end
948 |
949 | private
950 |
951 | def fetch_association name, value
952 | case value
953 | when Array
954 | value.map { |each| fetch_association Helper.singularize(name), each }
955 | when Hash
956 | Recurly.const_get(Helper.classify(name), false).send :new, value
957 | when Proc, Resource, Resource::Pager, nil
958 | value
959 | else
960 | raise "unexpected association #{name.inspect}=#{value.inspect}"
961 | end
962 | end
963 |
964 | def xml_keys
965 | changed_attributes.keys.sort
966 | end
967 | end
968 | end
969 |
--------------------------------------------------------------------------------