├── 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 [![Build Status](https://secure.travis-ci.org/recurly/recurly-client-ruby.png)](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 | --------------------------------------------------------------------------------