├── .document ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.4.0 ├── Gemfile.4.1 ├── Gemfile.4.2 ├── Gemfile.5.0 ├── Gemfile.5.1 ├── Gemfile.6 ├── LICENSE ├── README.markdown ├── Rakefile ├── acts_as_shopping_cart.gemspec ├── features ├── shopping_cart.feature ├── step_definitions │ ├── product_steps.rb │ └── shopping_cart_steps.rb └── support │ ├── env.rb │ └── rails_env.rb ├── lib ├── active_record │ └── acts │ │ ├── shopping_cart.rb │ │ ├── shopping_cart │ │ ├── collection.rb │ │ └── item.rb │ │ ├── shopping_cart_item.rb │ │ └── shopping_cart_item │ │ └── instance_methods.rb ├── acts_as_shopping_cart.rb └── acts_as_shopping_cart │ ├── schema.rb │ └── version.rb ├── script └── build.sh └── spec ├── .rspec ├── active_record └── acts │ ├── shopping_cart │ ├── collection_spec.rb │ └── item_spec.rb │ └── shopping_cart_item │ └── instance_methods_spec.rb └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile*.lock 4 | pkg/* 5 | .rvmrc 6 | coverage 7 | coverage.features 8 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Rails: 2 | Enabled: true 3 | 4 | AllCops: 5 | DisplayCopNames: true 6 | DisplayStyleGuide: true 7 | StyleGuideCopsOnly: true 8 | 9 | Style/StringLiterals: 10 | EnforcedStyle: double_quotes 11 | 12 | Metrics/LineLength: 13 | Max: 110 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | 4 | rvm: 5 | - 2.2.4 6 | - 2.3.2 7 | - 2.5.7 8 | - 2.6.5 9 | 10 | gemfile: 11 | - Gemfile.4.0 12 | - Gemfile.4.1 13 | - Gemfile.4.2 14 | - Gemfile.5.0 15 | - Gemfile.5.1 16 | - Gemfile.6 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.4.1 4 | 5 | - Renamed the items? methods to `items?` and `no_items?` 6 | 7 | ## 0.4.0 8 | 9 | - Updated dependencies to use it on Rails 5 10 | 11 | ### Breaking changes 12 | 13 | - Remove the `empty?` method and add `has_items?` and `has_no_items?`. 14 | - Updated money-rails dependency to at least 1.5 which can potentially break your app. 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.4.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 4.0.0' 7 | gem 'activerecord', '~> 4.0.0' 8 | -------------------------------------------------------------------------------- /Gemfile.4.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 4.1.0' 7 | gem 'activerecord', '~> 4.1.0' 8 | -------------------------------------------------------------------------------- /Gemfile.4.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 4.2.0' 7 | gem 'activerecord', '~> 4.2.0' 8 | -------------------------------------------------------------------------------- /Gemfile.5.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 5.0.0' 7 | gem 'activerecord', '~> 5.0.0' 8 | -------------------------------------------------------------------------------- /Gemfile.5.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 5.1.0' 7 | gem 'activerecord', '~> 5.1.0' 8 | -------------------------------------------------------------------------------- /Gemfile.6: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_shopping_cart.gemspec 4 | gemspec 5 | 6 | gem 'rails', '~> 6.0.0' 7 | gem 'activerecord', '~> 6.0.0' 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 David Padilla 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # acts_as_shopping_cart 2 | 3 | A simple shopping cart implementation. 4 | 5 | [![Build Status](https://secure.travis-ci.org/dabit/acts_as_shopping_cart.png?branch=master)](http://travis-ci.org/dabit/acts_as_shopping_cart) 6 | [![Gem Version](https://badge.fury.io/rb/acts_as_shopping_cart.svg)](https://badge.fury.io/rb/acts_as_shopping_cart) 7 | 8 | You can find an example application [here](https://github.com/dabit/acts_as_shopping_cart_app). 9 | 10 | ## Install 11 | 12 | ### Rails 3 13 | 14 | **As of Version 0.2.0 Rails 3 is no longer supported. Please use the 0-1-x branch 15 | if you still need to implement this gem in a Rails 3 app** 16 | 17 | Include it in your Gemfile 18 | 19 | gem 'acts_as_shopping_cart', :github => 'dabit/acts_as_shopping_cart', :branch => '0-1-x' 20 | 21 | And run bundler 22 | 23 | bundle install 24 | 25 | ### Rails 4 26 | 27 | Just include it in your Gemfile as: 28 | 29 | gem 'acts_as_shopping_cart', '~> 0.4.0' 30 | 31 | And run bundle install 32 | 33 | bundle install 34 | 35 | ## Usage 36 | 37 | You need two models, one to hold the `Shopping Carts` and another to hold the `Items` 38 | 39 | You can use any name for the models, you just have to let each model know about each other. 40 | 41 | ### Examples 42 | 43 | For the Shopping Cart: 44 | 45 | class Cart < ActiveRecord::Base 46 | acts_as_shopping_cart_using :cart_item 47 | end 48 | 49 | 50 | For the items: 51 | 52 | class CartItem < ActiveRecord::Base 53 | acts_as_shopping_cart_item_for :cart 54 | end 55 | 56 | or, if you want to use convention over configuration, make sure your models are 57 | named `ShoppingCart` and `ShoppingCartItem`, then just use the shortcuts: 58 | 59 | class ShoppingCart < ActiveRecord::Base 60 | acts_as_shopping_cart 61 | end 62 | 63 | class ShoppingCartItem < ActiveRecord::Base 64 | acts_as_shopping_cart_item 65 | end 66 | 67 | ### Migrations 68 | 69 | In order for this to work, the Shopping Cart Item model should have the following fields: 70 | 71 | create_table :cart_items do |t| 72 | t.shopping_cart_item_fields # Creates the cart items fields 73 | end 74 | 75 | ### Shopping Cart Items 76 | 77 | Your `ShoppingCart` class will have a `shopping_cart_items` association 78 | that returns all the `ShoppingCartItem` objects in your cart. 79 | 80 | ### Add Items 81 | 82 | To add an item to the cart you use the add method. You have to send the object 83 | and the price of the object as parameters. 84 | 85 | So, if you had a `Product` class, you would do something like this: 86 | 87 | @cart = Cart.create 88 | @product = Product.find(1) 89 | 90 | @cart.add(@product, 99.99) 91 | 92 | In the case where your product has a price field you could do something like: 93 | 94 | @cart.add(@product, @product.price) 95 | 96 | I tried to make it independent to the models in case you calculate discounts, 97 | sale prices or anything customized. 98 | 99 | You can include a quantity parameter too. 100 | 101 | @cart.add(@product, 99.99, 5) 102 | 103 | In that case, you would add 5 of the same products to the shopping cart. If you 104 | don't specify the quantity `1` will be assumed. 105 | 106 | ### Remove Items 107 | 108 | To remove an item from the cart you can use the remove method. You just have to 109 | send the object and the quantity you want to remove. 110 | 111 | @cart.remove(@product, 1) 112 | 113 | ### Empty the cart 114 | 115 | To remove all the items in the cart at once, just use the `clear` method 116 | 117 | @cart.clear 118 | 119 | ### Total 120 | 121 | You can find out about the total using the `total` method: 122 | 123 | @cart.total # => 99.99 124 | 125 | ### Taxes 126 | 127 | Taxes by default are calculated by multiplying subtotal times `8.25` 128 | 129 | If you want to change the way taxes are calculated, override the `taxes` 130 | method on your class that `acts_as_shopping_cart`. 131 | 132 | Example: 133 | 134 | class ShoppingCart < ActiveRecord::Base 135 | acts_as_shopping_cart 136 | 137 | def taxes 138 | (subtotal - 10) * 8.3 139 | end 140 | end 141 | 142 | If you just want to update the `percentage`, override the `tax_pct` 143 | method. 144 | 145 | class ShoppingCart < ActiveRecord::Base 146 | acts_as_shopping_cart 147 | 148 | def tax_pct 149 | 3.5 150 | end 151 | end 152 | 153 | ### Shipping Cost 154 | 155 | Shipping cost will be added to the total. By default its calculated as 156 | 0, but you can just override the shipping_cost method on your cart 157 | class depending on your needs. 158 | 159 | class ShoppingCart < ActiveRecord::Base 160 | acts_as_shopping_cart 161 | 162 | def shipping_cost 163 | 5 # defines a flat $5 rate 164 | end 165 | end 166 | 167 | ### Total unique items 168 | 169 | You can find out how many unique items you have on your cart using the `total_unique_items` method. 170 | 171 | So, if you have something like: 172 | 173 | @cart.add(@product, 99.99, 5) 174 | 175 | Then, 176 | 177 | @cart.total_unique_items # => 5 178 | 179 | ## Development 180 | 181 | Install the dependencies 182 | 183 | bundle install 184 | 185 | ### Test 186 | 187 | Run rspec 188 | 189 | rspec spec 190 | 191 | Run cucumber features 192 | 193 | cucumber 194 | 195 | Both: 196 | 197 | rake 198 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require "cucumber/rake/task" 4 | Cucumber::Rake::Task.new do |t| 5 | t.cucumber_opts = "--format progress" 6 | end 7 | 8 | require "rspec/core/rake_task" 9 | RSpec::Core::RakeTask.new do |t| 10 | t.fail_on_error = true 11 | end 12 | 13 | task default: [:spec, :cucumber] 14 | -------------------------------------------------------------------------------- /acts_as_shopping_cart.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 3 | 4 | require "acts_as_shopping_cart/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "acts_as_shopping_cart" 8 | s.version = ActsAsShoppingCart::VERSION 9 | s.authors = ["David Padilla"] 10 | s.email = ["david@padilla.io"] 11 | s.homepage = "" 12 | s.summary = "Simple Shopping Cart implementation" 13 | s.description = "Simple Shopping Cart implementation" 14 | 15 | s.rubyforge_project = "acts_as_shopping_cart" 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | 22 | s.add_dependency "rails", "> 4" 23 | s.add_dependency "money-rails", "~> 1.5" 24 | 25 | s.add_development_dependency "cucumber" 26 | s.add_development_dependency "database_cleaner" 27 | s.add_development_dependency "rake" 28 | s.add_development_dependency "rspec" 29 | s.add_development_dependency "sqlite3" 30 | s.add_development_dependency "simplecov" 31 | s.add_development_dependency "rubocop" 32 | end 33 | -------------------------------------------------------------------------------- /features/shopping_cart.feature: -------------------------------------------------------------------------------- 1 | Feature: Shopping Cart 2 | 3 | Background: 4 | Given a product "Apple" exists 5 | And a shopping cart exists 6 | 7 | Scenario: Cart totals 8 | When I add product "Apple" to cart with price "99.99" 9 | Then the subtotal for the cart should be "99.99" 10 | And the total for the cart should be "108.24" 11 | And the total unique items on the cart should be "1" 12 | 13 | Scenario: Cart Totals when cart is empty 14 | Then the subtotal for the cart should be "0" 15 | And the total for the cart should be "0" 16 | And the total unique items on the cart should be "0" 17 | 18 | Scenario: Add a product to cart twice 19 | When I add product "Apple" to cart with price "99.99" 20 | And I add product "Apple" to cart with price "99.99" 21 | Then the subtotal for the cart should be "199.98" 22 | Then the total for the cart should be "216.48" 23 | And the total unique items on the cart should be "2" 24 | 25 | Scenario: Add a product to cart twice non-cumulatively 26 | When I add product "Apple" to cart with price "99.99" 27 | And I add product "Apple" to cart with price "99.99" 28 | And I non-cumulatively add product "Apple" to cart with price "99.99" 29 | Then the subtotal for the cart should be "99.99" 30 | Then the total for the cart should be "108.24" 31 | And the total unique items on the cart should be "1" 32 | 33 | Scenario: Remove products from cart 34 | Given I add 3 "Apple" products to cart with price "99.99" 35 | When I remove 1 "Apple" unit from cart 36 | Then the total unique items on the cart should be "2" 37 | When I remove 99 "Apple" units from cart 38 | Then the total unique items on the cart should be "0" 39 | And cart should be empty 40 | 41 | Scenario: Totals for a single item 42 | Given I add 3 "Apple" products to cart with price "99.99" 43 | Then the subtotal for "Apple" on the cart should be "299.97" 44 | And the quantity for "Apple" on the cart should be "3" 45 | And the price for "Apple" on the cart should be "99.99" 46 | 47 | Scenario: Subtotal for a product that is not on cart 48 | Then the subtotal for "Apple" on the cart should be "0" 49 | 50 | Scenario: Update the quantity of a cart item 51 | Given I add 99 "Apple" products to cart with price "99.99" 52 | When I update the "Apple" quantity to "2" 53 | Then the quantity for "Apple" on the cart should be "2" 54 | 55 | Scenario: Update the price of a cart item 56 | Given I add 99 "Apple" products to cart with price "99.99" 57 | When I update the "Apple" price to "10.99" 58 | Then the price for "Apple" on the cart should be "10.99" 59 | 60 | Scenario: Empty the shopping cart 61 | Given I add 99 "Apple" products to cart with price "99.99" 62 | When I empty the cart 63 | Then cart should be empty 64 | And the total for the cart should be "0" 65 | 66 | Scenario: Item should hold a relation to cart 67 | When I add product "Apple" to cart with price "99.99" 68 | Then shopping cart item "Apple" should belong to cart 69 | 70 | 71 | -------------------------------------------------------------------------------- /features/step_definitions/product_steps.rb: -------------------------------------------------------------------------------- 1 | Given /^a product "([^"]*)" exists$/ do |name| 2 | Product.create(name: name) 3 | end 4 | -------------------------------------------------------------------------------- /features/step_definitions/shopping_cart_steps.rb: -------------------------------------------------------------------------------- 1 | Given /^a shopping cart exists$/ do 2 | @cart = ShoppingCart.create 3 | end 4 | 5 | Then /^the total for the cart should be "([^"]*)"$/ do |total| 6 | @cart.reload 7 | expect(@cart.total).to eq(Money.new(total.to_f * 100)) 8 | end 9 | 10 | Then /^the subtotal for the cart should be "([^"]*)"$/ do |subtotal| 11 | @cart.reload 12 | expect(@cart.subtotal).to eq(Money.new(subtotal.to_f * 100)) 13 | end 14 | 15 | When /^I add product "([^"]*)" to cart with price "([^"]*)"$/ do |product_name, price| 16 | product = Product.find_by_name(product_name) 17 | @cart.add(product, price) 18 | end 19 | 20 | When /^I non-cumulatively add product "([^"]*)" to cart with price "([^"]*)"$/ do |product_name, price| 21 | product = Product.find_by_name(product_name) 22 | @cart.add(product, price, 1, false) 23 | end 24 | 25 | Then /^the total unique items on the cart should be "([^"]*)"$/ do |total| 26 | @cart.reload 27 | expect(@cart.total_unique_items).to eq(total.to_i) 28 | end 29 | 30 | When /^I remove (\d+) "([^"]*)" unit(s?) from cart$/ do |quantity, product_name, _| 31 | @cart.reload 32 | product = Product.find_by_name(product_name) 33 | @cart.remove(product, quantity.to_i) 34 | end 35 | 36 | When /^I empty the cart$/ do 37 | @cart.clear 38 | end 39 | 40 | Then /^cart should be empty$/ do 41 | @cart.reload 42 | expect(@cart.has_no_items?).to be true 43 | end 44 | 45 | Given /^I add (\d+) "([^"]*)" products to cart with price "([^"]*)"$/ do |quantity, product_name, price| 46 | product = Product.find_by_name(product_name) 47 | @cart.add(product, price.to_f, quantity.to_i) 48 | end 49 | 50 | Then /^the subtotal for "([^"]*)" on the cart should be "([^"]*)"$/ do |product_name, subtotal| 51 | @cart.reload 52 | product = Product.find_by_name(product_name) 53 | expect(@cart.subtotal_for(product)).to eq(subtotal.to_f) 54 | end 55 | 56 | Then /^the quantity for "([^"]*)" on the cart should be "([^"]*)"$/ do |product_name, quantity| 57 | @cart.reload 58 | product = Product.find_by_name(product_name) 59 | expect(@cart.quantity_for(product)).to eq(quantity.to_f) 60 | end 61 | 62 | Then /^the price for "([^"]*)" on the cart should be "([^"]*)"$/ do |product_name, price| 63 | @cart.reload 64 | product = Product.find_by_name(product_name) 65 | expect(@cart.price_for(product)).to eq(Money.from_amount(price.to_f)) 66 | end 67 | 68 | When /^I update the "([^"]*)" quantity to "([^"]*)"$/ do |product_name, quantity| 69 | @cart.reload 70 | product = Product.find_by_name(product_name) 71 | @cart.update_quantity_for(product, quantity.to_i) 72 | end 73 | 74 | When /^I update the "([^"]*)" price to "([^"]*)"$/ do |product_name, price| 75 | @cart.reload 76 | product = Product.find_by_name(product_name) 77 | @cart.update_price_for(product, price.to_f) 78 | end 79 | 80 | Then /^shopping cart item "([^"]*)" should belong to cart$/ do |_| 81 | @cart.reload 82 | shopping_cart_item = ShoppingCartItem.last 83 | expect(shopping_cart_item.owner).to eq @cart 84 | end 85 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | require "active_record" 4 | require "database_cleaner" 5 | require "money-rails" 6 | require "rspec" 7 | 8 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 9 | 10 | require "acts_as_shopping_cart" 11 | 12 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 13 | 14 | MoneyRails::Hooks.init 15 | 16 | RSpec.configure do |config| 17 | config.raise_errors_for_deprecations! 18 | end 19 | 20 | require "simplecov" 21 | 22 | SimpleCov.coverage_dir "coverage.features" 23 | SimpleCov.start 24 | -------------------------------------------------------------------------------- /features/support/rails_env.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 2 | # ActiveRecord::Base.logger = Logger.new(STDOUT) 3 | 4 | ActiveRecord::Schema.define(version: 1) do 5 | create_table :shopping_carts 6 | create_table :shopping_cart_items do |t| 7 | t.shopping_cart_item_fields 8 | end 9 | 10 | create_table :products do |t| 11 | t.string :name 12 | end 13 | end 14 | 15 | class Product < ActiveRecord::Base 16 | 17 | end 18 | 19 | class ShoppingCart < ActiveRecord::Base 20 | acts_as_shopping_cart 21 | end 22 | 23 | class ShoppingCartItem < ActiveRecord::Base 24 | acts_as_shopping_cart_item 25 | end 26 | -------------------------------------------------------------------------------- /lib/active_record/acts/shopping_cart.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts 3 | module ShoppingCart 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | module ClassMethods 9 | # 10 | # Prepares the class to act as a cart. 11 | # 12 | # Receives as a parameter the name of the class that will hold the items 13 | # 14 | # Example: 15 | # 16 | # acts_as_shopping_cart :cart_item 17 | # 18 | # 19 | def acts_as_shopping_cart_using(item_class) 20 | send :include, ActiveRecord::Acts::ShoppingCart::Collection 21 | send :include, ActiveRecord::Acts::ShoppingCart::Item 22 | has_many :shopping_cart_items, class_name: item_class.to_s.classify, as: :owner, dependent: :destroy 23 | end 24 | 25 | # 26 | # Alias for: 27 | # 28 | # acts_as_shopping_cart_using :shopping_cart_item 29 | # 30 | def acts_as_shopping_cart 31 | acts_as_shopping_cart_using :shopping_cart_item 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/active_record/acts/shopping_cart/collection.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts 3 | module ShoppingCart 4 | module Collection 5 | # 6 | # Adds a product to the cart 7 | # 8 | def add(object, price, quantity = 1, cumulative = true) 9 | cart_item = item_for(object) 10 | 11 | if cart_item 12 | cumulative = cumulative == true ? cart_item.quantity : 0 13 | cart_item.quantity = (cumulative + quantity) 14 | cart_item.save 15 | cart_item 16 | else 17 | shopping_cart_items.create(item: object, price: price, quantity: quantity) 18 | end 19 | end 20 | 21 | # 22 | # Deletes all shopping_cart_items in the shopping_cart 23 | # 24 | def clear 25 | shopping_cart_items.clear 26 | end 27 | 28 | # 29 | # Returns true when the cart has items 30 | # 31 | def items? 32 | shopping_cart_items.any? 33 | end 34 | alias :has_items? :items? 35 | 36 | # 37 | # Returns true when the cart is empty 38 | # 39 | def no_items? 40 | shopping_cart_items.empty? 41 | end 42 | alias :has_no_items? :no_items? 43 | 44 | 45 | # 46 | # Remove an item from the cart 47 | # 48 | def remove(object, quantity = 1) 49 | cart_item = item_for(object) 50 | 51 | return unless cart_item 52 | 53 | if cart_item.quantity <= quantity 54 | cart_item.delete 55 | else 56 | cart_item.quantity = (cart_item.quantity - quantity) 57 | cart_item.save 58 | end 59 | end 60 | 61 | # 62 | # Returns the subtotal by summing the price times quantity for all the 63 | # items in the cart 64 | # 65 | def subtotal 66 | shopping_cart_items.inject(Money.new(0)) { |a, e| a + (e.price * e.quantity) } 67 | end 68 | 69 | def shipping_cost 70 | Money.new(0) 71 | end 72 | 73 | def taxes 74 | subtotal * tax_pct * 0.01 75 | end 76 | 77 | def tax_pct 78 | 8.25 79 | end 80 | 81 | # 82 | # Returns the total by summing the subtotal, taxes and shipping_cost 83 | # 84 | def total 85 | reload 86 | subtotal + taxes + shipping_cost 87 | end 88 | 89 | # 90 | # Return the number of unique items in the cart 91 | # 92 | def total_unique_items 93 | shopping_cart_items.map(&:quantity).sum 94 | end 95 | 96 | def cart_items 97 | warn "ShoppingCart#cart_items WILL BE DEPRECATED IN LATER VERSIONS OF acts_as_shopping_cart," \ 98 | " please use ShoppingCart#shopping_cart_items instead" 99 | 100 | shopping_cart_items 101 | end 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/active_record/acts/shopping_cart/item.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts 3 | module ShoppingCart 4 | module Item 5 | 6 | # 7 | # Returns the cart item for the specified object 8 | # 9 | def item_for(object) 10 | shopping_cart_items.where(item: object).first 11 | end 12 | 13 | # 14 | # Returns the subtotal of a specified item by multiplying the quantity times 15 | # the price of the item. 16 | # 17 | def subtotal_for(object) 18 | item = item_for(object) 19 | item ? item.subtotal : 0 20 | end 21 | 22 | # 23 | # Returns the quantity of the specified object 24 | # 25 | def quantity_for(object) 26 | item = item_for(object) 27 | item ? item.quantity : 0 28 | end 29 | 30 | # 31 | # Updates the quantity of the specified object 32 | # 33 | def update_quantity_for(object, new_quantity) 34 | item = item_for(object) 35 | item.update_quantity(new_quantity) if item 36 | end 37 | 38 | # 39 | # Returns the price of the specified object 40 | # 41 | def price_for(object) 42 | item = item_for(object) 43 | item ? item.price : 0 44 | end 45 | 46 | # 47 | # Updates the price of the specified object 48 | # 49 | def update_price_for(object, new_price) 50 | item = item_for(object) 51 | item.update_price(new_price) if item 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/active_record/acts/shopping_cart_item.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts 3 | module ShoppingCartItem 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | module ClassMethods 9 | # 10 | # Prepares the class to act as a cart item. 11 | # 12 | # Receives as a parameter the name of the class that acts as a cart 13 | # 14 | # Example: 15 | # 16 | # acts_as_shopping_cart_item :cart 17 | # 18 | # 19 | def acts_as_shopping_cart_item_for(*) 20 | send :include, ActiveRecord::Acts::ShoppingCartItem::InstanceMethods 21 | belongs_to :owner, polymorphic: true 22 | belongs_to :item, polymorphic: true 23 | monetize :price_cents 24 | end 25 | 26 | # 27 | # Alias for: 28 | # 29 | # acts_as_shopping_cart_item_for :shopping_cart 30 | # 31 | def acts_as_shopping_cart_item 32 | acts_as_shopping_cart_item_for :shopping_cart 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/active_record/acts/shopping_cart_item/instance_methods.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts 3 | module ShoppingCartItem 4 | module InstanceMethods 5 | # 6 | # Returns the subtotal, multiplying the quantity times the price of the item. 7 | # 8 | def subtotal 9 | format("%.2f", quantity * price).to_f 10 | end 11 | 12 | # 13 | # Updates the quantity of the item 14 | # 15 | def update_quantity(new_quantity) 16 | self.quantity = new_quantity 17 | save 18 | end 19 | 20 | # 21 | # Updates the price of the item 22 | # 23 | def update_price(new_price) 24 | self.price = new_price 25 | save 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/acts_as_shopping_cart.rb: -------------------------------------------------------------------------------- 1 | require "acts_as_shopping_cart/version" 2 | require "rails" 3 | require "money-rails" 4 | require "active_record/acts/shopping_cart" 5 | require "active_record/acts/shopping_cart_item" 6 | require "acts_as_shopping_cart/schema" 7 | 8 | module ActiveRecord 9 | module Acts 10 | module ShoppingCart 11 | autoload :Collection , "active_record/acts/shopping_cart/collection" 12 | autoload :Item , "active_record/acts/shopping_cart/item" 13 | end 14 | 15 | module ShoppingCartItem 16 | autoload :InstanceMethods, "active_record/acts/shopping_cart_item/instance_methods" 17 | end 18 | end 19 | end 20 | 21 | ActiveRecord::Base.send :include, ActiveRecord::Acts::ShoppingCart 22 | ActiveRecord::Base.send :include, ActiveRecord::Acts::ShoppingCartItem 23 | -------------------------------------------------------------------------------- /lib/acts_as_shopping_cart/schema.rb: -------------------------------------------------------------------------------- 1 | require "active_record/connection_adapters/abstract/schema_definitions" 2 | 3 | module ActsAsShoppingCart 4 | module Schema 5 | def shopping_cart_item_fields 6 | integer :owner_id # Holds the owner id, for polymorphism 7 | string :owner_type # Holds the type of the owner, for polymorphism 8 | integer :quantity # Holds the quantity of the object 9 | integer :item_id # Holds the object id 10 | string :item_type # Holds the type of the object, for polymorphism 11 | integer :price_cents, default: 0, null: false # Holds the price of the item 12 | string :price_currency, default: "USD", null: false # Holds the currency for the price 13 | end 14 | end 15 | end 16 | 17 | ActiveRecord::ConnectionAdapters::Table.send :include, ActsAsShoppingCart::Schema 18 | ActiveRecord::ConnectionAdapters::TableDefinition.send :include, ActsAsShoppingCart::Schema 19 | -------------------------------------------------------------------------------- /lib/acts_as_shopping_cart/version.rb: -------------------------------------------------------------------------------- 1 | module ActsAsShoppingCart 2 | VERSION = "0.4.1" 3 | end 4 | -------------------------------------------------------------------------------- /script/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | CC_RUBY=1.9.3 4 | CC_GEMSET=aasc 5 | 6 | # Initialize RVM 7 | source "$HOME/.rvm/scripts/rvm" 8 | 9 | # Change gemset 10 | rvm $CC_RUBY@$CC_GEMSET --create 11 | 12 | # Is bundler installed? 13 | bundle -v || gem install bundler 14 | 15 | # Go get the dependencies 16 | bundle install 17 | 18 | bundle exec rake 19 | -------------------------------------------------------------------------------- /spec/.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /spec/active_record/acts/shopping_cart/collection_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "../../../../spec_helper") 2 | # require "spec_helper" 3 | 4 | describe ActiveRecord::Acts::ShoppingCart::Collection do 5 | let(:klass) do 6 | klass = Class.new 7 | klass.send(:include, ActiveRecord::Acts::ShoppingCart::Collection) 8 | end 9 | 10 | let(:subject) do 11 | subject = klass.new 12 | allow(subject).to receive(:shopping_cart_items).and_return([]) 13 | subject 14 | end 15 | 16 | let(:object) { double } 17 | 18 | let(:shopping_cart_item) do 19 | instance_double("shopping_cart_item", quantity: 2, save: true) 20 | end 21 | 22 | describe :add do 23 | context "item is not on cart" do 24 | before do 25 | allow(subject).to receive(:item_for).with(object) 26 | end 27 | 28 | it "creates a new shopping cart item" do 29 | created_object = double 30 | expect(subject.shopping_cart_items).to receive(:create) 31 | .with(item: object, price: 19.99, quantity: 3) 32 | .and_return(created_object) 33 | item = subject.add(object, 19.99, 3) 34 | expect(item).to be created_object 35 | end 36 | end 37 | 38 | context "item is not in cart" do 39 | before do 40 | allow(subject).to receive(:item_for).with(object) 41 | end 42 | 43 | it "creates a new shopping cart item non-cumulatively" do 44 | expect(subject.shopping_cart_items).to receive(:create).with(item: object, price: 19.99, quantity: 3) 45 | subject.add(object, 19.99, 3, false) 46 | end 47 | end 48 | 49 | context "item is already on cart" do 50 | before do 51 | allow(subject).to receive(:item_for).with(object).and_return(shopping_cart_item) 52 | end 53 | 54 | it "updates the quantity for the item" do 55 | expect(shopping_cart_item).to receive(:quantity=).with(5) 56 | item = subject.add(object, 19.99, 3) 57 | expect(item).to be shopping_cart_item 58 | end 59 | end 60 | 61 | context "item is already in cart" do 62 | before do 63 | allow(subject).to receive(:item_for).with(object).and_return(shopping_cart_item) 64 | end 65 | 66 | it "updates the quantity for the item non-cumulatively" do 67 | expect(shopping_cart_item).to receive(:quantity=).with(3) # not 5 68 | subject.add(object, 19.99, 3, false) 69 | end 70 | end 71 | end 72 | 73 | describe :clear do 74 | before do 75 | expect(subject.shopping_cart_items).to receive(:clear) 76 | end 77 | 78 | it "clears all the items in the cart" do 79 | subject.clear 80 | expect(subject.no_items?).to be true 81 | end 82 | end 83 | 84 | describe "items?" do 85 | context "cart has items" do 86 | before do 87 | subject.shopping_cart_items << double 88 | end 89 | 90 | it "returns true" do 91 | expect(subject.items?).to be true 92 | end 93 | end 94 | 95 | context "cart is empty" do 96 | it "returns false" do 97 | expect(subject.items?).to be false 98 | end 99 | end 100 | end 101 | 102 | describe "no_items?" do 103 | context "cart has items" do 104 | before do 105 | subject.shopping_cart_items << double 106 | end 107 | 108 | it "returns false" do 109 | expect(subject.no_items?).to be false 110 | end 111 | end 112 | 113 | context "cart is empty" do 114 | it "returns true" do 115 | expect(subject.no_items?).to be true 116 | end 117 | end 118 | end 119 | 120 | describe :remove do 121 | context "item is not on cart" do 122 | before do 123 | allow(subject).to receive(:item_for).with(object) 124 | end 125 | 126 | it "does nothing" do 127 | subject.remove(object) 128 | end 129 | end 130 | 131 | context "item is on cart" do 132 | before do 133 | allow(subject).to receive(:item_for).with(object).and_return(shopping_cart_item) 134 | end 135 | 136 | context "remove less items than those on cart" do 137 | it "just updates the shopping cart item quantity" do 138 | expect(shopping_cart_item).to receive(:quantity=).with(1) 139 | subject.remove(object, 1) 140 | end 141 | end 142 | 143 | context "remove more items than those on cart" do 144 | it "removes the shopping cart item object completely" do 145 | expect(shopping_cart_item).to receive(:delete) 146 | subject.remove(object, 99) 147 | end 148 | end 149 | end 150 | end 151 | 152 | describe :subtotal do 153 | context "cart has no items" do 154 | before do 155 | allow(subject).to receive(:shopping_cart_items).and_return([]) 156 | end 157 | 158 | it "returns 0" do 159 | expect(subject.subtotal).to be_an_instance_of(Money) 160 | expect(subject.subtotal).to eq(Money.new(0)) 161 | end 162 | end 163 | 164 | context "cart has items" do 165 | before do 166 | items = [instance_double("item 1", quantity: 2, price: Money.new(3399)), 167 | instance_double("item 2", quantity: 1, price: Money.new(4599))] 168 | allow(subject).to receive(:shopping_cart_items).and_return(items) 169 | end 170 | 171 | it "returns the sum of the price * quantity for all items" do 172 | expect(subject.subtotal).to be_an_instance_of(Money) 173 | expect(subject.subtotal).to eq(Money.new(11_397)) 174 | end 175 | end 176 | end 177 | 178 | describe :shipping_cost do 179 | it "returns 0" do 180 | expect(subject.shipping_cost).to be_an_instance_of Money 181 | expect(subject.shipping_cost).to eq(Money.new(0)) 182 | end 183 | end 184 | 185 | describe :taxes do 186 | context "subtotal is 100" do 187 | before do 188 | allow(subject).to receive(:subtotal).and_return(Money.new(10_000)) 189 | end 190 | 191 | it "returns 8.25" do 192 | expect(subject.taxes).to be_an_instance_of Money 193 | expect(subject.taxes).to eq(Money.new(825)) 194 | end 195 | end 196 | end 197 | 198 | describe :tax_pct do 199 | it "returns 8.25" do 200 | expect(subject.tax_pct).to eq(8.25) 201 | end 202 | end 203 | 204 | describe :total do 205 | before do 206 | allow(subject).to receive_messages( 207 | subtotal: Money.new(1099), 208 | taxes: Money.new(1399), 209 | shipping_cost: Money.new(1299), 210 | reload: nil 211 | ) 212 | end 213 | 214 | it "returns subtotal + taxes + shipping_cost" do 215 | expect(subject.total).to be_an_instance_of Money 216 | expect(subject.total).to eq(Money.new(3797)) 217 | end 218 | end 219 | 220 | describe :total_unique_items do 221 | context "cart has no items" do 222 | it "returns 0" do 223 | expect(subject.total_unique_items).to eq(0) 224 | end 225 | end 226 | 227 | context "cart has some items" do 228 | before do 229 | items = [instance_double("item 1", quantity: 2, price: 33.99), 230 | instance_double("item 2", quantity: 1, price: 45.99)] 231 | allow(subject).to receive(:shopping_cart_items).and_return(items) 232 | end 233 | 234 | it "returns the sum of the quantities of all shopping cart items" do 235 | expect(subject.total_unique_items).to eq(3) 236 | end 237 | end 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /spec/active_record/acts/shopping_cart/item_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "../../../../spec_helper") 2 | 3 | describe ActiveRecord::Acts::ShoppingCart::Item do 4 | let(:klass) do 5 | klass = Class.new 6 | klass.send :include, ActiveRecord::Acts::ShoppingCart::Item 7 | klass 8 | end 9 | 10 | let(:shopping_cart_items) { double } 11 | 12 | let(:subject) do 13 | subject = klass.new 14 | allow(subject).to receive(:shopping_cart_items).and_return(shopping_cart_items) 15 | subject 16 | end 17 | 18 | let(:object) { instance_double("object", id: 1) } 19 | let(:item) { instance_double("item", subtotal: 47.98, price: 23.99, quantity: 2, save: true)} 20 | 21 | describe :item_for do 22 | context "no cart item exists for the object" do 23 | before do 24 | expect(shopping_cart_items).to receive(:where).with(item: object).and_return([]) 25 | end 26 | 27 | it "returns the shopping cart item object for the requested object" do 28 | expect(subject.item_for(object)).to be_nil 29 | end 30 | end 31 | 32 | context "a cart item exists for the object" do 33 | before do 34 | expect(shopping_cart_items).to receive(:where).with(item: object).and_return([item]) 35 | end 36 | 37 | it "returns that item" do 38 | expect(subject.item_for(object)).to be(item) 39 | end 40 | end 41 | end 42 | 43 | describe :subtotal_for do 44 | context "no cart item exists for the object" do 45 | before do 46 | expect(subject).to receive(:item_for).with(object) 47 | end 48 | 49 | it "returns 0" do 50 | expect(subject.subtotal_for(object)).to eq(0.0) 51 | end 52 | end 53 | 54 | context "the cart item exists for the object" do 55 | before do 56 | expect(subject).to receive(:item_for).with(object).and_return(item) 57 | end 58 | 59 | it "returns the subtotal for the item" do 60 | expect(subject.subtotal_for(object)).to eq(47.98) 61 | end 62 | end 63 | end 64 | 65 | describe :quantity_for do 66 | context "no cart item exists for the object" do 67 | before do 68 | expect(subject).to receive(:item_for).with(object) 69 | end 70 | 71 | it "returns 0" do 72 | expect(subject.quantity_for(object)).to eq(0) 73 | end 74 | end 75 | 76 | context "the cart item exists for the object" do 77 | before do 78 | expect(subject).to receive(:item_for).with(object).and_return(item) 79 | end 80 | 81 | it "returns the quantity for the object" do 82 | expect(subject.quantity_for(object)).to eq(2) 83 | end 84 | end 85 | end 86 | 87 | describe :update_quantity_for do 88 | context "the cart item exists for the object" do 89 | before do 90 | expect(subject).to receive(:item_for).with(object).and_return(item) 91 | end 92 | 93 | it "updates the item quantity to the specified quantity" do 94 | expect(item).to receive(:update_quantity).with(3) 95 | subject.update_quantity_for(object, 3) 96 | end 97 | end 98 | end 99 | 100 | describe :price_for do 101 | context "no cart item exists for the object" do 102 | before do 103 | expect(subject).to receive(:item_for).with(object) 104 | end 105 | 106 | it "returns 0" do 107 | expect(subject.price_for(object)).to eq(0.0) 108 | end 109 | end 110 | 111 | context "the cart item exists for the object" do 112 | before do 113 | expect(subject).to receive(:item_for).with(object).and_return(item) 114 | end 115 | 116 | it "returns the price of the item" do 117 | expect(subject.price_for(object)).to eq(23.99) 118 | end 119 | end 120 | end 121 | 122 | describe :update_price_for do 123 | context "the cart item exists for the object" do 124 | before do 125 | expect(subject).to receive(:item_for).with(object).and_return(item) 126 | end 127 | 128 | it "updates the price on the item" do 129 | expect(item).to receive(:update_price).with(99.99) 130 | subject.update_price_for(object, 99.99) 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/active_record/acts/shopping_cart_item/instance_methods_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "../../../../spec_helper") 2 | 3 | describe ActiveRecord::Acts::ShoppingCartItem::InstanceMethods do 4 | let(:klass) do 5 | klass = Class.new 6 | klass.send :include, ActiveRecord::Acts::ShoppingCartItem::InstanceMethods 7 | klass 8 | end 9 | 10 | let(:subject) do 11 | subject = klass.new 12 | allow(subject).to receive(:save).and_return(true) 13 | subject 14 | end 15 | 16 | describe :subtotal do 17 | it "returns the quantity * price" do 18 | allow(subject).to receive_messages(quantity: 2, price: 33.99) 19 | expect(subject.subtotal).to eq(67.98) 20 | end 21 | end 22 | 23 | describe :update_quantity do 24 | it "updates the item quantity" do 25 | expect(subject).to receive(:quantity=).with(5) 26 | expect(subject).to receive(:save) 27 | subject.update_quantity(5) 28 | end 29 | end 30 | 31 | describe :update_price do 32 | it "updates the item price" do 33 | expect(subject).to receive(:price=).with(55.99) 34 | expect(subject).to receive(:save) 35 | subject.update_price(55.99) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | 4 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 5 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) 6 | 7 | require "simplecov" 8 | require "rails" 9 | require "active_record" 10 | require "money-rails" 11 | 12 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 13 | 14 | MoneyRails::Hooks.init 15 | require "acts_as_shopping_cart" 16 | 17 | SimpleCov.start 18 | 19 | RSpec.configure do |config| 20 | config.mock_with :rspec 21 | config.raise_errors_for_deprecations! 22 | end 23 | --------------------------------------------------------------------------------