yodel_shop 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in yodel_shop.gemspec
4
+ gemspec
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,16 @@
1
+ class ShopProductModelMigration < Migration
2
+ def self.up(site)
3
+ site.pages.create_model :products do |products|
4
+ add_field :price, :decimal, validations: {required: {}}, default: '0.0'
5
+ add_field :shipping, :decimal, validations: {required: {}}, default: '0.0'
6
+ add_field :tax, :decimal, validations: {required: {}}, default: '0.0'
7
+ add_field :unlimited_quantity, :boolean, validations: {required: {}}, default: false
8
+ add_field :quantity, :integer, validations: {required: {}}, default: 0, index: true
9
+ add_many :holds, model: :product_hold
10
+ end
11
+ end
12
+
13
+ def self.down(site)
14
+ site.products.destroy
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ class ShopUserUpdatesMigration < Migration
2
+ def self.up(site)
3
+ site.users.modify do
4
+ add_field :balance, :decimal, validations: {required: {}}, default: '0.0'
5
+ end
6
+ end
7
+
8
+ def self.down(site)
9
+ site.users.modify do
10
+ remove_field :balance
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ class ShopTransactionModelMigration < Migration
2
+ def self.up(site)
3
+ site.records.create_model :transactions do |transactions|
4
+ add_one :cart
5
+ add_field :product_total, :decimal, default: '0.0', validations: {required: {}}
6
+ add_field :shipping_total, :decimal, default: '0.0', validations: {required: {}}
7
+ add_field :tax_total, :decimal, default: '0.0', validations: {required: {}}
8
+ add_field :total, :function, fn: 'sum(product_total, shipping_total, tax_total)'
9
+ add_field :created_at, :time
10
+
11
+ add_embed_one :shipping_address do
12
+ add_field :name, :string
13
+ add_field :address, :string
14
+ add_field :city, :string
15
+ add_field :state, :string
16
+ add_field :country, :string
17
+ add_field :postcode, :string
18
+ add_field :phone, :string
19
+ add_field :email, :string
20
+ end
21
+
22
+ add_embed_one :billing_address do
23
+ add_field :name, :string
24
+ add_field :address, :string
25
+ add_field :city, :string
26
+ add_field :state, :string
27
+ add_field :country, :string
28
+ add_field :postcode, :string
29
+ add_field :phone, :string
30
+ add_field :email, :string
31
+ end
32
+
33
+ add_field :payment_reference, :string
34
+
35
+ # permissions
36
+ users_group = site.groups['Users'].id
37
+ transactions.view_group = users_group
38
+ transactions.create_group = users_group
39
+ transactions.update_group = users_group
40
+ transactions.delete_group = users_group
41
+ end
42
+
43
+ site.pages.create_model :transaction_pages do |transaction_pages|
44
+ transaction_pages.record_class_name = 'TransactionPage'
45
+ end
46
+ end
47
+
48
+ def self.down(site)
49
+ site.transactions.destroy
50
+ site.transaction_pages.destroy
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ class ShopCartModelMigration < Migration
2
+ def self.up(site)
3
+ site.records.create_model :carts do |carts|
4
+ add_field :session_id, :string
5
+ add_one :user, index: true
6
+ add_many :product_holds, foreign_key: 'cart', destroy: true
7
+ add_field :created_at, :time
8
+ add_field :updated_at, :time
9
+ add_field :hold_duration, :integer, default: 600
10
+ add_one :transaction, index: true
11
+ carts.record_class_name = 'Cart'
12
+
13
+ # permissions
14
+ users_group = site.groups['Users'].id
15
+ carts.view_group = users_group
16
+ carts.create_group = users_group
17
+ carts.update_group = users_group
18
+ carts.delete_group = users_group
19
+ end
20
+
21
+ site.pages.create_model :cart_pages do |cart_pages|
22
+ cart_pages.record_class_name = 'CartPage'
23
+ end
24
+ end
25
+
26
+ def self.down(site)
27
+ site.carts.destroy
28
+ site.cart_pages.destroy
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ class ShopProductHoldModelMigration < Migration
2
+ def self.up(site)
3
+ site.records.create_model :product_holds do |product_holds|
4
+ add_one :product, validations: {required: {}}, index: true
5
+ add_one :cart, validations: {required: {}}, index: true
6
+ add_field :quantity, :integer, default: 0, validations: {required: {}}
7
+ add_field :sold, :boolean, default: false
8
+ product_holds.record_class_name = 'ProductHold'
9
+ end
10
+ end
11
+
12
+ def self.down(site)
13
+ site.product_holds.destroy
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ class ShopCouponModelMigration < Migration
2
+ def self.up(site)
3
+ site.records.create_model :coupons do |coupons|
4
+ add_field :code, :string
5
+ add_field :value, :decimal
6
+ end
7
+
8
+ site.records.create_model :coupon_redemptions do |coupon_redemptions|
9
+ add_one :user
10
+ add_one :coupon
11
+ end
12
+ end
13
+
14
+ def self.down(site)
15
+ site.coupons.destroy
16
+ site.coupon_redemptions.destroy
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ class CouponRedemptionPageModelMigration < Migration
2
+ def self.up(site)
3
+ site.pages.create_model :coupon_redemption_pages do |coupon_redemption_pages|
4
+ add_one :redirect_to, model: :page
5
+ coupon_redemption_pages.record_class_name = 'CouponRedemptionPage'
6
+ end
7
+ end
8
+
9
+ def self.down(site)
10
+ site.coupon_redemption_pages.destroy
11
+ end
12
+ end
@@ -0,0 +1,37 @@
1
+ class ShopModelFunctionsMigration < Migration
2
+ def self.up(site)
3
+ site.products.modify do |products|
4
+ add_field :total_cost, :function, fn: 'sum(price, shipping, tax)'
5
+ end
6
+
7
+ site.product_holds.modify do |products|
8
+ add_field :total_cost, :function, fn: 'multiply(product.total_cost, quantity)'
9
+ end
10
+
11
+ site.carts.modify do |carts|
12
+ add_field :total_cost, :function, fn: 'product_holds.sum(total_cost)'
13
+ end
14
+
15
+ site.transactions.modify do |transactions|
16
+ remove_field :product_total
17
+ remove_field :shipping_total
18
+ remove_field :tax_total
19
+ remove_field :total
20
+
21
+ modify_field :shipping_address do |shipping_address|
22
+ remove_field :name
23
+ add_field :first_name, :string
24
+ add_field :last_name, :string
25
+ end
26
+
27
+ modify_field :billing_address do |billing_address|
28
+ remove_field :name
29
+ add_field :first_name, :string
30
+ add_field :last_name, :string
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.down(site)
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ class ShopCartDurationMigration < Migration
2
+ def self.up(site)
3
+ # duration is now stored outside a cart
4
+ site.carts.modify do |carts|
5
+ remove_field :hold_duration
6
+ end
7
+
8
+ # clean up carts every minute
9
+ cart_task = Task.new(site)
10
+ cart_task.type = 'perform_destroy_stale_carts'
11
+ cart_task.repeat_in = 60
12
+ cart_task.save
13
+ end
14
+
15
+ def self.down(site)
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ class CouponRestrictionsMigration < Migration
2
+ def self.up(site)
3
+ site.coupon_redemptions.destroy
4
+
5
+ site.coupons.modify do |coupons|
6
+ add_field :user_restrictions, :hash
7
+ add_field :product_restrictions, :hash
8
+ add_field :value_type, :enum, options: %w{currency percent}, default: 'currency'
9
+ add_many :redemptions, model: :user
10
+ end
11
+ end
12
+
13
+ def self.down(site)
14
+ site.coupons.modify do |coupons|
15
+ remove_field :user_restrictions
16
+ remove_field :product_restrictions
17
+ remove_field :value_type
18
+ remove_field :redemptions
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ class ShopDiscountModelMigration < Migration
2
+ def self.up(site)
3
+ site.records.create_model :discounts do |discounts|
4
+ add_field :amount, :integer, validations: {required: {}}, default: '0.0'
5
+ end
6
+
7
+ site.carts.modify do |carts|
8
+ add_many :discounts
9
+ modify_field :total_cost, fn: 'subtract(product_holds.sum(total_cost), discounts.sum(amount))'
10
+ end
11
+ end
12
+
13
+ def self.down(site)
14
+ site.discounts.destroy
15
+ site.carts.modify do |carts|
16
+ remove_field :discounts
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ class Cart < Record
2
+ CART_HOLD_DURATION = 10 * 60 # 10 minutes
3
+
4
+ before_destroy :delete_all_product_holds
5
+ def delete_all_product_holds
6
+ product_holds.each(&:destroy)
7
+ end
8
+
9
+ def self.perform_destroy_stale_carts(site)
10
+ # ignore carts which have been purchased
11
+ query = site.carts.where(transaction: nil)
12
+ query = query.where(updated_at: {'$lt' => Time.at(Time.now.utc.to_i - CART_HOLD_DURATION)})
13
+ query.all.each(&:destroy)
14
+ end
15
+ end
@@ -0,0 +1,65 @@
1
+ class CartPage < Page
2
+ def products
3
+ cart.product_holds.collect(&:product)
4
+ end
5
+
6
+ def hold_product(product, quantity)
7
+ # TODO: find_or_create
8
+ # use any existing holds for this product
9
+ hold = site.product_holds.where(product: product.id, cart: cart.id).first
10
+ hold ||= site.product_holds.new(product: product, cart: cart)
11
+
12
+ if product.quantity >= quantity
13
+ if product.increment! :quantity, -quantity, :quantity.gt => 0
14
+ hold.quantity += quantity
15
+ return hold.save
16
+ end
17
+ end
18
+ false
19
+ end
20
+
21
+ def cart
22
+ if logged_in?
23
+ @cart ||= site.carts.where(transaction: nil, user: current_user.id).order('created desc').first
24
+ else
25
+ # TODO: need to track by session
26
+ nil
27
+ end
28
+ end
29
+
30
+ respond_to :post do
31
+ with :html do
32
+ return unless user_allowed_to?(:create)
33
+
34
+ # we're creating a new cart, so delete any existing (current) carts
35
+ site.carts.where(transaction: nil, user: current_user.id).all.each(&:destroy)
36
+
37
+ # create a new cart to hold any products sent in the request
38
+ @cart = site.carts.new(user: current_user, name: current_user.name)
39
+ @cart.save
40
+
41
+ # if any products were added to the cart, create holds on them
42
+ flash[:failed] = []
43
+
44
+ params['products'].each do |product_options|
45
+ product = site.products.where(_id: BSON::ObjectId.from_string(product_options['id'])).first
46
+ quantity = product_options['quantity'].to_i
47
+ unless product.unlimited_quantity || hold_product(product, quantity)
48
+ flash[:failed] << product_options['id']
49
+ end
50
+ end
51
+
52
+ # finally render the cart page
53
+ cart.reload
54
+ respond_to_get_with_html
55
+ end
56
+ end
57
+
58
+ respond_to :put do
59
+ with :html do
60
+ return unless user_allowed_to?(:update)
61
+ # FIXME: implement
62
+ end
63
+ end
64
+
65
+ end
@@ -0,0 +1,44 @@
1
+ class CouponRedemptionPage < Page
2
+ respond_to :post do
3
+ with :html do
4
+ user = current_user
5
+ coupon = site.coupons.where(code: params['coupon_code']).first
6
+
7
+ if user && coupon
8
+ unless coupon.redemptions.include?(user)
9
+ if user_permitted_to_redeem?(user, coupon)
10
+ coupon.redemptions << user
11
+ coupon.save
12
+
13
+ # FIXME: needs to be atomic and respect percent value_type
14
+ user.balance += coupon.value
15
+ user.save_without_validation
16
+ flash[:coupon_successfully_redeemed] = true
17
+ else
18
+ flash[:failed_redemption_rules] = true
19
+ end
20
+ else
21
+ flash[:coupon_already_redeemed] = true
22
+ end
23
+ else
24
+ flash[:coupon_not_found] = true
25
+ end
26
+
27
+ response.redirect redirect_to.path
28
+ end
29
+ end
30
+
31
+ private
32
+ def user_permitted_to_redeem?(user, coupon)
33
+ return true if coupon.user_restrictions.blank?
34
+
35
+ coupon.user_restrictions.each do |field, restriction|
36
+ # TODO: support other restriction types
37
+ if restriction.is_a?(Array)
38
+ return false unless restriction.include?(user.get(field))
39
+ end
40
+ end
41
+
42
+ true
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ class ProductHold < Record
2
+ after_destroy :increment_quantity
3
+ def increment_quantity
4
+ return if product.unlimited_quantity
5
+ product.increment! :quantity
6
+ end
7
+ end
@@ -0,0 +1,138 @@
1
+ class TransactionPage < Page
2
+
3
+ # TODO: find way to abstract this
4
+ def cart
5
+ if logged_in?
6
+ @cart ||= site.carts.where(transaction: nil, user: current_user.id).order('created desc').first
7
+ else
8
+ # TODO: need to track by session
9
+ nil
10
+ end
11
+ end
12
+
13
+ def successful?
14
+ @successful
15
+ end
16
+
17
+ def products
18
+ cart.product_holds.collect(&:product)
19
+ end
20
+
21
+ respond_to :post do
22
+ with :html do
23
+ return unless user_allowed_to?(:create)
24
+
25
+ # update the cart from the checkout form (e.g to include discounts)
26
+ cart.from_json(params['cart']) if params['cart']
27
+
28
+ # FIXME: race condition (user a window 1, user a window 2, both doing purchases, inconsistent charge)
29
+ # Unable to do an atomic update because big decimals are stored as strings... store as int (*100)??
30
+ current_user.balance -= cart.total_cost
31
+ current_user.save_without_validation
32
+ transaction_successful = false
33
+ transaction_id = BSON::ObjectId.new
34
+
35
+ if current_user.balance < 0
36
+ gateway = ActiveMerchant::Billing::PayWayGateway.new(
37
+ username: 'Q14555',
38
+ password: 'Au5xh8v8g',
39
+ merchant: '23891864',
40
+ pem: File.join(site.root_directory, 'ccapi.pem'),
41
+ eci: 'SSL'
42
+ )
43
+
44
+ # construct an expiry month & date from a date string or separate m/y values
45
+ if params['expiry_month'] && params['expiry_year']
46
+ expiry_month = params['expiry_month']
47
+ expiry_year = params['expiry_year']
48
+ else
49
+ begin
50
+ expiry = Date.parse(params['expiry'].to_s)
51
+ expiry_month = expiry.month.to_s
52
+ expiry_year = expiry.year.to_s
53
+ rescue
54
+ expiry_month = expiry_month = nil
55
+ end
56
+ end
57
+
58
+ # ignore spaces and any non numeric characters
59
+ card_number = params['card_number'].scan(/\d+/).join
60
+
61
+ # use the current user's name if no other name is provided
62
+ first_name = params['first_name'] || current_user.first_name
63
+ last_name = params['last_name'] || current_user.last_name
64
+
65
+ card = ActiveMerchant::Billing::CreditCard.new(
66
+ number: card_number,
67
+ month: expiry_month,
68
+ year: expiry_year,
69
+ first_name: first_name,
70
+ last_name: last_name,
71
+ verification_value: params['verification']
72
+ )
73
+
74
+ if card.valid?
75
+ order_number = [transaction_id.data.collect(&:chr).join].pack('m0')
76
+ result = gateway.purchase((current_user.balance * -100).to_i, card, order_number: order_number)
77
+ if result.success?
78
+ payment_reference = result.params['receipt_no']
79
+ transaction_successful = true
80
+ else
81
+ flash[:transact_error] = result.message
82
+ end
83
+ else
84
+ flash[:card_error] = card.errors.collect {|field, val| "#{field.humanize} #{val.to_sentence}"}.join('. ')
85
+ end
86
+ else
87
+ payment_reference = 'Positive Balance'
88
+ transaction_successful = true
89
+ end
90
+
91
+ if transaction_successful
92
+ transaction = site.transactions.new(cart: cart)
93
+ transaction.set_id(transaction_id)
94
+
95
+ # billing
96
+ transaction.billing_address.first_name = params['first_name']
97
+ transaction.billing_address.last_name = params['last_name']
98
+
99
+ # shipping
100
+ transaction.shipping_address.first_name = current_user.first_name
101
+ transaction.shipping_address.last_name = current_user.last_name
102
+ transaction.shipping_address.address = current_user.address
103
+ transaction.shipping_address.state = current_user.state
104
+ transaction.shipping_address.city = current_user.city
105
+ transaction.shipping_address.postcode = current_user.postcode
106
+ transaction.shipping_address.phone = current_user.phone
107
+ transaction.shipping_address.email = current_user.email
108
+
109
+ # transaction
110
+ transaction.payment_reference = payment_reference
111
+ transaction.save
112
+
113
+ # mark the products as sold
114
+ # TODO: switch to hold.update(sold: true)
115
+ cart.product_holds.each do |hold|
116
+ hold.sold = true
117
+ hold.save
118
+ end
119
+
120
+ cart.transaction = transaction
121
+ cart.save
122
+
123
+ @successful = true
124
+ respond_to_get_with_html()
125
+ else
126
+ current_user.balance += cart.total_cost
127
+ current_user.save_without_validation
128
+ flash[:first_name] = params['first_name']
129
+ flash[:last_name] = params['last_name']
130
+ flash[:expiry] = params['expiry']
131
+ flash[:card_number] = params['card_number']
132
+ flash[:verification] = params['verification']
133
+ response.redirect('/cart')
134
+ end
135
+ end
136
+ end
137
+
138
+ end
data/lib/yodel_shop.rb ADDED
@@ -0,0 +1,3 @@
1
+ module YodelShop
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,22 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require 'yodel_shop'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'yodel_shop'
7
+ s.version = YodelShop::VERSION
8
+ s.authors = ['Will Cannings']
9
+ s.email = ['me@willcannings.com']
10
+ s.homepage = 'http://yodelcms.com'
11
+ s.summary = 'Yodel CMS Shop Extension'
12
+ s.description = 'Yodel CMS Shop Extension'
13
+
14
+ s.files = `git ls-files`.split("\n")
15
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
17
+ s.require_paths = ['lib']
18
+
19
+ # specify any dependencies here; for example:
20
+ # s.add_development_dependency "rspec"
21
+ # s.add_runtime_dependency "rest-client"
22
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yodel_shop
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Will Cannings
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-12-09 00:00:00.000000000Z
13
+ dependencies: []
14
+ description: Yodel CMS Shop Extension
15
+ email:
16
+ - me@willcannings.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - .gitignore
22
+ - Gemfile
23
+ - Rakefile
24
+ - lib/migrations/01_shop_product_model.rb
25
+ - lib/migrations/02_shop_user_updates.rb
26
+ - lib/migrations/03_shop_transaction_model.rb
27
+ - lib/migrations/04_shop_cart_model.rb
28
+ - lib/migrations/05_shop_product_hold_model.rb
29
+ - lib/migrations/06_shop_coupon_model.rb
30
+ - lib/migrations/07_shop_coupon_redemption_page_model.rb
31
+ - lib/migrations/08_shop_model_functions.rb
32
+ - lib/migrations/09_shop_cart_duration_task.rb
33
+ - lib/migrations/10_shop_coupon_restrictions.rb
34
+ - lib/migrations/11_shop_discount_model.rb
35
+ - lib/models/cart.rb
36
+ - lib/models/cart_page.rb
37
+ - lib/models/coupon_redemption_page.rb
38
+ - lib/models/product_hold.rb
39
+ - lib/models/transaction_page.rb
40
+ - lib/yodel_shop.rb
41
+ - yodel_shop.gemspec
42
+ homepage: http://yodelcms.com
43
+ licenses: []
44
+ post_install_message:
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ! '>='
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubyforge_project:
62
+ rubygems_version: 1.8.10
63
+ signing_key:
64
+ specification_version: 3
65
+ summary: Yodel CMS Shop Extension
66
+ test_files: []