yodel_shop 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []