unit4-checkout 0.1.3 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83982e7ddae1c09934ca1125ae9cfb0b0b8b845638fbce0eb19472137057652b
4
- data.tar.gz: fb8982c906834e642dcaedbd81cdb89f258555c1dcbab7f73dc1387a9cd609fa
3
+ metadata.gz: 8fffe71c0723cfa6a79cc0061c0dd84bf59b2da58a5355a6711a4c4411f6e498
4
+ data.tar.gz: a7270ea6bcfdb6333afe24f16ed101962237bba4ff0c5c749add5cbe921f6a80
5
5
  SHA512:
6
- metadata.gz: 514d5a25a55f090eb87ebcd5d49ce013b7638efa885e79dd59270c361bcb0462f929140706413d04624a5bfdfdad757f07648dc8403f188d72b07fe36b48bee3
7
- data.tar.gz: 1721ec7136a1c447903ffbfdbad3ce2c7d0fea7ff11c19eed5ca57a5adfcdd3895b16d930d3a34a910dfd2a21ccf5dc08cf7d200f2945872ceb80502fa74b4da
6
+ metadata.gz: 4149594b3b6a036a6c3bea54bc01e900f49989e4a71ec10a79d2d05a8e2ee97a9a959fb3877d82791c27658b7bff6a59b286460dd2636c4c79e2683eeb5d5eb8
7
+ data.tar.gz: a805ebc11314abbf80714c132cae8f77b914c5918abd3c3d42e6284045603e845c4d00e57f894dbba7b7308a570f4b2a8377120f342da064bba06d0281e628f6
data/.rubocop.yml CHANGED
@@ -11,3 +11,6 @@ Style/StringLiteralsInInterpolation:
11
11
 
12
12
  Layout/LineLength:
13
13
  Max: 120
14
+
15
+ Metrics/BlockLength:
16
+ IgnoredMethods: ['describe', 'context']
data/Gemfile.lock CHANGED
@@ -1,7 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- unit4-checkout (0.1.2)
4
+ unit4-checkout (0.2.2)
5
+ activerecord (~> 7.0.3)
6
+ erb
5
7
 
6
8
  GEM
7
9
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -1,9 +1,3 @@
1
- # Unit4::Checkout
2
-
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/unit4/checkout`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
6
-
7
1
  ## Installation
8
2
 
9
3
  Install the gem and add to the application's Gemfile by executing:
@@ -16,14 +10,48 @@ If bundler is not being used to manage dependencies, install the gem by executin
16
10
 
17
11
  ## Usage
18
12
 
19
- TODO: Write usage instructions here
13
+ Please note the gem relies on a database connection to the "products" table. Please make sure you have that table created and products have the 'price' attribute (float, non-nil).
14
+
15
+ Create a Checkout instance with OPTIONAL promotional rules:
16
+
17
+ ```
18
+ co = Checkout.new(promotional_rules)
19
+ ```
20
+ The promotional rules need to have the following structure (note the "=>" after the item id that is used for a key):
21
+
22
+ ```
23
+ promotional_rules = { product_discounts: { "001" => { count: 2, price: 8.50 } },
24
+ total_price_discount: { price: 60.00, percent: 10 } }
25
+ ```
26
+
27
+ Scanning items:
28
+
29
+ ```
30
+ co.scan("001")
31
+ ```
32
+
33
+ Access the total price:
34
+
35
+ ```
36
+ co.total
37
+ ```
38
+
39
+ ## Testing
40
+
41
+ The gem has its own database config and database for testing that complies with the requested format
42
+
43
+ To run the test suite in the console using RSpec, run
44
+
45
+ ```
46
+ rspec
47
+ ```
20
48
 
21
- ## Development
49
+ ## Future work
22
50
 
23
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
51
+ Use a user supplied database instead of the "products" one. Different attribute names for price can be incorporated too.
24
52
 
25
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
53
+ Test the gem thoroughly against a plain Ruby project. Environment will either be (most likely) non-existent there so some changes in the `Connection.non_rails_db_config` will be needed.
26
54
 
27
55
  ## Contributing
28
56
 
29
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/unit4-checkout.
57
+ Bug reports and pull requests are welcome on GitHub at https://github.com/BoyanGeorgiev96/unit4-checkout.
@@ -0,0 +1,25 @@
1
+ # SQLite. Versions 3.8.0 and up are supported.
2
+ # gem install sqlite3
3
+ #
4
+ # Ensure the SQLite 3 gem is defined in your Gemfile
5
+ # gem "sqlite3"
6
+ #
7
+ default: &default
8
+ adapter: sqlite3
9
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10
+ timeout: 5000
11
+
12
+ development:
13
+ <<: *default
14
+ database: db/development.sqlite3
15
+
16
+ # Warning: The database defined as "test" will be erased and
17
+ # re-generated from your development database when you run "rake".
18
+ # Do not set this db to the same as development or production.
19
+ test:
20
+ <<: *default
21
+ database: db/test.sqlite3
22
+
23
+ production:
24
+ <<: *default
25
+ database: db/production.sqlite3
Binary file
@@ -1,7 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # basket should probably be separated from the checkout, hence this class
1
4
  class Basket
2
5
  attr_accessor :items
3
6
 
4
7
  def initialize
5
8
  @items = {}
6
9
  end
7
- end
10
+ end
@@ -1,72 +1,122 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
- require "erb"
5
-
3
+ # the main class that is used for scaning the items and applying the discounts
6
4
  class Checkout
7
- attr_reader :promotional_rules, :total, :basket, :price_discount_applied_flag, :result
5
+ attr_reader :total
8
6
 
9
- # {product_discounts: {001: {count:2, price: 3.25}, 004: {count:2, price: 3.25}}, total_price_discount: $50}
10
- def initialize(promotional_rules)
11
- # maybe raise another error if keys aren't ids or total price, possibly total_item_count
12
- raise TypeError, "expected a Hash, got #{promotional_rules.class.name}" unless promotional_rules.is_a? Hash
7
+ def initialize(promotional_rules = {})
8
+ # custom exceptions for incorrect Checkout.new({...}) parameters
9
+ raise PromotionalRulesTypeError, promotional_rules.class.name unless promotional_rules.is_a? Hash
10
+ raise PromotionalRulesForbiddenKeys unless correct_keys?(promotional_rules)
13
11
 
14
12
  @promotional_rules = promotional_rules
15
13
  @total = 0
16
14
  @basket = Basket.new
17
- establish_connection
15
+ @saved_prices = {}
16
+ create_db_connection
18
17
  end
19
18
 
20
- # maybe facilitate scanning multiple items at once
21
- # no structure for item given, assumed id
19
+ # no structure for the item variable item given, assumed id from sample data input
22
20
  def scan(item)
23
- # check for item id
24
21
  add_to_basket(item)
25
22
  calculate_total(item)
26
- puts "#{item.capitalize} has been added to the basket successfully!"
23
+ puts "Item with ID '#{item}' has been added to the basket successfully!"
24
+ end
25
+
26
+ private
27
+
28
+ def correct_keys?(promotional_rules)
29
+ (promotional_rules.keys - %i[product_discounts total_price_discount]).empty?
27
30
  end
28
31
 
32
+ def create_db_connection
33
+ Connection.new
34
+ end
35
+
36
+ # add to total according to several criteria:
37
+ # is the total already discounted, is the item eligible for a discount based on the number of times it got scanned
38
+ # the function also decides whether both discounts or only one is required
39
+
29
40
  def calculate_total(item)
30
- find_item_price(item)
31
- item_price = 3
32
- new_discount_available?(item) ? apply_discounts : @total += item_price
33
- @total
41
+ item_price = item_price(item)
42
+ # use a hash to store seen non-discounted prices so the database is not queried when we already have the info
43
+ @saved_prices[item] ||= item_price
44
+ if @total_price_discount_applied_flag
45
+ item_discounted?(item) ? apply_item_and_total_discount(item, item_price) : apply_total_discount_on_item(item_price)
46
+ else
47
+ item_discounted?(item) ? apply_item_discount(item, item_price) : @total += item_price
48
+ # apply total discount if the last item moved the price sent the discount threshold
49
+ apply_total_discount if total_price_discounted?
50
+ end
51
+ @total = @total.round(2)
34
52
  end
35
53
 
36
- def find_item_price(item)
37
- query = "SELECT * FROM 'users' WHERE 'users'.'id' = ?"
38
- sanitized_query = ActiveRecord::Base.sanitize_sql_array([query, item])
39
- execute_statement(sanitized_query)
54
+ def item_price(item)
55
+ # Future work: facilitate DB table name different from products, i.e. ask gem user for db name
56
+ PriceQuery.new(item).find_price(@saved_prices)
40
57
  end
41
58
 
42
- def apply_discounts; end
59
+ # apply both types of discounts at the same time
60
+ def apply_item_and_total_discount(item, item_price)
61
+ item_prom_rules = @promotional_rules[:product_discounts][item]
62
+ item_basket_count = @basket.items[item]
63
+ total_discount = (1 - @promotional_rules[:total_price_discount][:percent] / 100.00)
64
+ @total += item_and_total(item_basket_count, item_prom_rules, total_discount, item_price)
65
+ end
43
66
 
44
- def new_discount_available?(item); end
67
+ # if it is the first time the specific item has been discounted we discount all its other instances
68
+ # no need to keep track of every item in-memory, so we just use the total count of the item to remove
69
+ # the old sum for this specific item and add the new discounted one
70
+ def item_and_total(item_basket_count, item_prom_rules, total_discount, item_price)
71
+ if item_basket_count == item_prom_rules[:count]
72
+ (item_basket_count * item_prom_rules[:price] - (item_basket_count - 1) * item_price) * total_discount
73
+ else
74
+ item_basket_count * item_prom_rules[:price] * total_discount
75
+ end
76
+ end
45
77
 
46
- def execute_statement(sql)
47
- results = ActiveRecord::Base.connection.exec_query(sql)
48
- @result = results if results.present?
78
+ # used when only total discount is needed for a non-discounted scanned item, e.g. when the total sum is over $60.00
79
+ def apply_total_discount_on_item(item_price)
80
+ @total += item_price * (1 - @promotional_rules[:total_price_discount][:percent] / 100.00)
49
81
  end
50
82
 
51
- def establish_connection
52
- db_config = setup_db_config
53
- ActiveRecord::Base.establish_connection(adapter: db_config["adapter"], database: db_config["database"])
83
+ # if item is not in the promotional rules returns false
84
+ # otherwise check if the item count in the basket is higher than the promotional rules threshold
85
+ def item_discounted?(item)
86
+ return false unless item_in_discounts?(item)
87
+
88
+ @basket.items[item] >= @promotional_rules[:product_discounts][item][:count]
54
89
  end
55
90
 
56
- def setup_db_config
57
- defined?(Rails) && defined?(Rails.env) ? rails_db_config : non_rails_db_config
91
+ def item_in_discounts?(item)
92
+ @promotional_rules[:product_discounts] && @promotional_rules[:product_discounts][item]
58
93
  end
59
94
 
60
- def rails_db_config
61
- Rails.application.config.database_configuration[Rails.env]
95
+ # only applies the specific item discount. same reasoning as the "item_and_total" function.
96
+ def apply_item_discount(item, item_price)
97
+ item_prom_rules = @promotional_rules[:product_discounts][item]
98
+ item_basket_count = @basket.items[item]
99
+ @total += if item_basket_count == item_prom_rules[:count]
100
+ item_basket_count * item_prom_rules[:price] - (item_basket_count - 1) * item_price
101
+ else
102
+ item_prom_rules[:price]
103
+ end
62
104
  end
63
105
 
64
- def non_rails_db_config
65
- # TODO: use current database instead of development
66
- YAML.safe_load(ERB.new(File.read("./config/database.yml")).result, aliases: true)["development"]
106
+ def total_price_discounted?
107
+ return false unless total_price_in_discounts?
108
+
109
+ @total >= @promotional_rules[:total_price_discount][:price]
67
110
  end
68
111
 
69
- # maybe add remove item function
112
+ def total_price_in_discounts?
113
+ @promotional_rules[:total_price_discount] && @promotional_rules[:total_price_discount][:price]
114
+ end
115
+
116
+ def apply_total_discount
117
+ @total *= (1 - @promotional_rules[:total_price_discount][:percent] / 100.00)
118
+ @total_price_discount_applied_flag = true
119
+ end
70
120
 
71
121
  def add_to_basket(item)
72
122
  @basket.items[item] ? @basket.items[item] += 1 : @basket.items[item] = 1
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "erb"
5
+
6
+ # creates the connection for both Rails and non-Rails applications
7
+ class Connection
8
+ def initialize
9
+ db_config = setup_db_config
10
+ ActiveRecord::Base.establish_connection(adapter: db_config["adapter"], database: db_config["database"])
11
+ end
12
+
13
+ def setup_db_config
14
+ # decide whether the application that uses the gem is a Rails one or a plain Ruby
15
+ defined?(Rails) && defined?(Rails.env) ? rails_db_config : non_rails_db_config
16
+ end
17
+
18
+ def rails_db_config
19
+ Rails.application.config.database_configuration[Rails.env]
20
+ end
21
+
22
+ def non_rails_db_config
23
+ # plain Ruby doesn't really have a sense of environments (unless a custom one has been set up)
24
+ # the development key will most likely be changed when the gem is used by a plain Ruby application
25
+ # the database db and config used here are samples from a Rails application, but this works with plain Ruby too
26
+ # Future work: test thoroughly on plain Ruby applications
27
+ YAML.safe_load(ERB.new(File.read("./config/database.yml")).result, aliases: true)["development"]
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ # custom exception for when the user does not provide a hash
2
+ class PromotionalRulesTypeError < TypeError
3
+ def initialize(class_name, msg = "expected a Hash, got ", exception_type = "custom")
4
+ msg << class_name
5
+ @exception_type = exception_type
6
+ super(msg)
7
+ end
8
+ end
9
+
10
+ # custom exception for when the hash has unaccceptable keys
11
+ class PromotionalRulesForbiddenKeys < TypeError
12
+ def initialize(msg = "expected Hash with optional keys 'product_discounts' and 'total_price_discount", exception_type = "custom")
13
+ @exception_type = exception_type
14
+ super(msg)
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # used for querying the database for the non-discounted product price
4
+ class PriceQuery
5
+ def initialize(item)
6
+ @sanitized_query = prepare_sql_statement(item)
7
+ @item = item
8
+ end
9
+
10
+ def prepare_sql_statement(item)
11
+ # Future work: facilitate DB table name different from products, i.e. ask gem user for db name
12
+ ActiveRecord::Base.sanitize_sql_array(["SELECT 'products'.'price' FROM 'products' WHERE 'products'.'id' = ?", item])
13
+ end
14
+
15
+ def find_price(saved_prices)
16
+ # grab the price from the hash if available
17
+ return saved_prices[@item] if saved_prices[@item]
18
+
19
+ results = ActiveRecord::Base.connection.exec_query(@sanitized_query)
20
+ results.rows.first.first if results.present?
21
+ end
22
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Unit4
4
4
  module Checkout
5
- VERSION = "0.1.3"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
@@ -3,10 +3,11 @@
3
3
  require_relative "checkout/version"
4
4
  require_relative "checkout/checkout"
5
5
  require_relative "checkout/basket"
6
+ require_relative "checkout/connection"
7
+ require_relative "checkout/price_query"
8
+ require_relative "checkout/exceptions"
6
9
 
7
10
  module Unit4
8
- module Checkout
9
- class Error < StandardError; end
10
- # Your code goes here...
11
- end
11
+ # the main module for the gem, no code really needed here with the current structure
12
+ module Checkout; end
12
13
  end
metadata CHANGED
@@ -1,16 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unit4-checkout
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Boyan Georgiev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-07-21 00:00:00.000000000 Z
12
- dependencies: []
13
- description: Initial commit
11
+ date: 2022-07-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.3
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.3
27
+ - !ruby/object:Gem::Dependency
28
+ name: erb
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.2.3
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.2.3
41
+ description: A Ruby gem that helps the user implement a checkout system by supplying
42
+ only the promotional rules
14
43
  email:
15
44
  - bbgeorgiev96@gmail.com
16
45
  executables: []
@@ -23,13 +52,19 @@ files:
23
52
  - Gemfile.lock
24
53
  - README.md
25
54
  - Rakefile
55
+ - config/database.yml
56
+ - db/development.sqlite3
26
57
  - lib/unit4/checkout.rb
27
58
  - lib/unit4/checkout/basket.rb
28
59
  - lib/unit4/checkout/checkout.rb
60
+ - lib/unit4/checkout/connection.rb
61
+ - lib/unit4/checkout/exceptions.rb
62
+ - lib/unit4/checkout/price_query.rb
29
63
  - lib/unit4/checkout/version.rb
30
64
  - sig/unit4/checkout.rbs
31
65
  homepage: https://github.com/BoyanGeorgiev96/unit4-checkout
32
- licenses: []
66
+ licenses:
67
+ - MIT
33
68
  metadata:
34
69
  allowed_push_host: https://rubygems.org/
35
70
  homepage_uri: https://github.com/BoyanGeorgiev96/unit4-checkout
@@ -52,5 +87,5 @@ requirements: []
52
87
  rubygems_version: 3.3.3
53
88
  signing_key:
54
89
  specification_version: 4
55
- summary: Initial commit
90
+ summary: Checkout gem for Unit4 technical task
56
91
  test_files: []