unit4-checkout 0.1.3 → 0.2.2

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.
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: []