halffare 0.1.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: bda0442ac9dab0587de36dde13f84c71f1c45753
4
+ data.tar.gz: 16e0a906faeda411f7a5b4bd6b57ffc38eae4d2d
5
+ SHA512:
6
+ metadata.gz: f2c60bf02b70ce2f8d9fcc65f07461bd84f9ff50ae694bb2e6da28625313ed2b1335f312861bef01c5f1c6fa15ff24ef7dab1283453493a1f5b6160289f11373
7
+ data.tar.gz: 908e0da3000e7155e1c1ce3b7da14ce772a3d4fca00860939667871fc07541607baa71e7761b1b8943c517b84c8eafe0decd8f785cd373a10a99b1e427410ce3
@@ -0,0 +1,38 @@
1
+ = halffare
2
+ --------------------------------------
3
+ This software is in no way affiliated with, authorized, maintained, sponsored or endorsed by sbb.ch or any of its affiliates or subsidiaries.
4
+ --------------------------------------
5
+
6
+ +halffare+ evaluates whether a SBB Half-Fare travelcard is profitable based on your online order history.
7
+
8
+ Provides commands to download your online order history from sbb.ch and evaluate it.
9
+
10
+ {<img src="https://raw.githubusercontent.com/rndstr/halffare/master/media/halffare-results.png" alt="halffare evaluation results" />}
11
+
12
+
13
+ == Getting started
14
+
15
+ Install
16
+
17
+ $ gem install halffare
18
+
19
+ Download order history
20
+
21
+ $ halffare fetch --months=12 --output=year.halffare
22
+
23
+ Evaluate orders
24
+
25
+ $ halffare stats --input=year.halffare
26
+
27
+ More options
28
+
29
+ $ halffare help
30
+
31
+ == Usage
32
+
33
+ :include:halffare.rdoc
34
+
35
+ == License
36
+
37
+ {MIT license}[https://github.com/rndstr/halffare/blob/master/LICENSE]
38
+
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ require 'gli'
3
+ require 'halffare'
4
+
5
+ include GLI::App
6
+
7
+ program_desc 'Evaluates whether a SBB Half-Fare travelcard is profitable based on your online order history'
8
+
9
+ version Halffare::VERSION
10
+
11
+ desc 'Fetches your order history from sbb.ch and stores them in a file'
12
+ command :fetch do |c|
13
+ c.desc 'Username for sbb.ch'
14
+ c.arg_name 'username'
15
+ c.flag [:u,:username]
16
+
17
+ c.desc 'Password for sbb.ch'
18
+ c.arg_name 'password'
19
+ c.flag [:p,:password]
20
+
21
+ c.desc 'Filename to store the data in'
22
+ c.default_value 'orders.halffare'
23
+ c.arg_name 'output'
24
+ c.flag [:o, :output]
25
+
26
+ c.desc 'Number of pages to retrieve'
27
+ c.default_value 32
28
+ c.arg_name 'count'
29
+ c.flag [:pages]
30
+
31
+ c.desc 'Stop fetching orders when reaching months back'
32
+ c.arg_name :months
33
+ c.flag [:m, :months]
34
+
35
+ c.desc 'Overwrite an existing data file'
36
+ c.switch :force, :negatable => false
37
+
38
+ c.desc 'Also print debug output'
39
+ c.switch :debug, :negatable => false
40
+
41
+ c.action do |global_options,options,args|
42
+ Halffare.debug = options[:debug]
43
+ if File.exists?(options[:output]) && !options[:force]
44
+ log_error "file #{options[:output]} exists, use --force or --output"
45
+ else
46
+ fetch = Halffare::Fetch.new()
47
+ fetch.login(options[:username], options[:password])
48
+ fetch.download(options[:output], options[:pages], options[:months])
49
+ end
50
+ end
51
+ end
52
+
53
+ desc "Calculates and displays stats about your order history"
54
+ command :stats do |c|
55
+ c.desc 'Filename to read the data from'
56
+ c.default_value 'orders.halffare'
57
+ c.arg_name 'input'
58
+ c.flag [:i, :input]
59
+
60
+ c.desc 'Strategy to use for determining price savings [guess, sbb, sbbguess]'
61
+ c.default_value :sbbguess
62
+ c.arg_name :strategy
63
+ c.flag [:s, :strategy]
64
+ # c.must_match [:sbb, :guess, 'sbbguess']
65
+
66
+ c.desc 'Prices found in input file are of type [half, full, ask]'
67
+ c.default_value :ask
68
+ c.arg_name :pricetype
69
+ c.flag [:p, :pricetype]
70
+ # c.must_match [:half, :full, :ask]
71
+
72
+ c.desc 'Restrict how many months in the past to consider orders'
73
+ c.arg_name :months
74
+ c.flag [:m, :months]
75
+
76
+ c.desc 'Also print debug output'
77
+ c.switch :debug, :negatable => false
78
+
79
+ c.action do |global_options,options,args|
80
+ Halffare.debug = options[:debug]
81
+ stats = Halffare::Stats.new
82
+ stats.read(options[:input], options[:months])
83
+ if stats.count > 0
84
+ if options[:pricetype] == :ask
85
+ options[:pricetype] = yesno('Did you buy your tickets with a half-fare card?', nil) ? 'half' : 'full';
86
+ end
87
+ stats.calculate(options[:strategy], options[:pricetype] == 'half')
88
+ stats.display(options[:pricetype] == 'half')
89
+ end
90
+ end
91
+ end
92
+
93
+ pre do |global,command,options,args|
94
+ # Pre logic here
95
+ # Return true to proceed; false to abort and not call the
96
+ # chosen command
97
+ # Use skips_pre before a command to skip this block
98
+ # on that command only
99
+ true
100
+ end
101
+
102
+ post do |global,command,options,args|
103
+ # Post logic here
104
+ # Use skips_post before a command to skip this
105
+ # block on that command only
106
+ end
107
+
108
+ on_error do |exception|
109
+ p exception
110
+ p exception.backtrace.join("\n")
111
+ # Error logic here
112
+ # return false to skip default error handling
113
+ true
114
+ end
115
+
116
+ exit run(ARGV)
@@ -0,0 +1,114 @@
1
+ == halffare - Evaluates whether a SBB Half-Fare travelcard is profitable based on your online order history
2
+
3
+ v0.1.1
4
+
5
+ === Global Options
6
+ === --help
7
+ Show this message
8
+
9
+
10
+
11
+ === --version
12
+ Display the program version
13
+
14
+
15
+
16
+ === Commands
17
+ ==== Command: <tt>fetch </tt>
18
+ Fetches your order history from sbb.ch and stores them in a file
19
+
20
+
21
+ ===== Options
22
+ ===== -m|--months months
23
+
24
+ Stop fetching orders when reaching months back
25
+
26
+ [Default Value] None
27
+
28
+
29
+ ===== -o|--output output
30
+
31
+ Filename to store the data in
32
+
33
+ [Default Value] orders.halffare
34
+
35
+
36
+ ===== -p|--password password
37
+
38
+ Password for sbb.ch
39
+
40
+ [Default Value] None
41
+
42
+
43
+ ===== --pages count
44
+
45
+ Number of pages to retrieve
46
+
47
+ [Default Value] 32
48
+
49
+
50
+ ===== -u|--username username
51
+
52
+ Username for sbb.ch
53
+
54
+ [Default Value] None
55
+
56
+
57
+ ===== --debug
58
+ Also print debug output
59
+
60
+
61
+
62
+ ===== --force
63
+ Overwrite an existing data file
64
+
65
+
66
+
67
+ ==== Command: <tt>help command</tt>
68
+ Shows a list of commands or help for one command
69
+
70
+ Gets help for the application or its commands. Can also list the commands in a way helpful to creating a bash-style completion function
71
+ ===== Options
72
+ ===== -c
73
+ List commands one per line, to assist with shell completion
74
+
75
+
76
+
77
+ ==== Command: <tt>stats </tt>
78
+ Calculates and displays stats about your order history
79
+
80
+
81
+ ===== Options
82
+ ===== -i|--input input
83
+
84
+ Filename to read the data from
85
+
86
+ [Default Value] orders.halffare
87
+
88
+
89
+ ===== -m|--months months
90
+
91
+ Restrict how many months in the past to consider orders
92
+
93
+ [Default Value] None
94
+
95
+
96
+ ===== -p|--pricetype pricetype
97
+
98
+ Prices found in input file are of type [half, full, ask]
99
+
100
+ [Default Value] ask
101
+
102
+
103
+ ===== -s|--strategy strategy
104
+
105
+ Strategy to use for determining price savings [guess, sbb, sbbguess]
106
+
107
+ [Default Value] sbbguess
108
+
109
+
110
+ ===== --debug
111
+ Also print debug output
112
+
113
+
114
+
@@ -0,0 +1,69 @@
1
+ require 'mechanize'
2
+ require 'highline/import'
3
+ require 'logger'
4
+ require 'date'
5
+ require 'yaml'
6
+
7
+ require 'halffare/version.rb'
8
+ require 'halffare/fetch.rb'
9
+ require 'halffare/stats.rb'
10
+ require 'halffare/model/order.rb'
11
+ require 'halffare/price.rb'
12
+ require 'halffare/price_base.rb'
13
+ require 'halffare/price_guess.rb'
14
+ require 'halffare/price_sbb.rb'
15
+
16
+ module Kernel
17
+ private
18
+ def currency(cash)
19
+ 'CHF' + sprintf('%.2f', cash.to_f.abs)
20
+ end
21
+
22
+ def log_debug(str)
23
+ say(str) if Halffare.debug
24
+ end
25
+
26
+ def log_info(str)
27
+ say("#{str}")
28
+ end
29
+
30
+ def log_notice(str)
31
+ say("<%= color('#{str.gsub("'","\\\\'")}', BOLD) %>")
32
+ end
33
+
34
+ def log_heading(str)
35
+ say("<%= color('#{str.gsub("'","\\\\'")}', YELLOW, BOLD) %>")
36
+ end
37
+
38
+ def log_result(str)
39
+ say("<%= color('#{str.gsub("'","\\\\'")}', GREEN) %>")
40
+ end
41
+
42
+ def log_emphasize(str)
43
+ say("<%= color('#{str.gsub("'","\\\\'")}', CYAN) %>")
44
+ end
45
+
46
+ def log_error(str)
47
+ say("<%= color('#{'ERROR: ' + str.gsub("'","\\\\'")}', WHITE, ON_RED, BOLD) %>")
48
+ end
49
+
50
+ def log_order(order)
51
+ log_info "\n"
52
+ log_heading "#{order.order_date} #{order.description}"
53
+ log_emphasize "You paid: #{currency(order.price)}"
54
+ end
55
+
56
+ def yesno(prompt = 'Continue?', default = true)
57
+ a = ''
58
+ s = '[y/n]'
59
+ unless default.nil?
60
+ s = default ? '[Y/n]' : '[y/N]'
61
+ end
62
+ d = default ? 'y' : 'n'
63
+ until %w[y n].include? a
64
+ a = ask("#{prompt} #{s} ") { |q| q.limit = 1; q.case = :downcase }
65
+ a = d if a.length == 0
66
+ end
67
+ a == 'y'
68
+ end
69
+ end
@@ -0,0 +1,112 @@
1
+ module Halffare
2
+
3
+ class Fetch
4
+ USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.64 Safari/537.11'
5
+ URL_LOGIN = 'http://www.sbb.ch/meta/login.html'
6
+ URL_ORDERS = 'https://www.sbb.ch/ticketshop/b2c/dossierListe.do'
7
+ ORDER_NOTE_FILE_CREATED = 'halffare-orders-file-created'
8
+
9
+ def initialize()
10
+ @agent = ::Mechanize.new
11
+ @agent.user_agent = USER_AGENT
12
+ end
13
+
14
+ def login(username, password)
15
+ username = ask("sbb.ch username? ") unless username
16
+ password = ask("sbb.ch password? ") { |q| q.echo = "*" } unless password
17
+ log_info "logging in..."
18
+
19
+ #@agent.log = Logger.new('debug.log')
20
+
21
+ @response = @agent.get URL_LOGIN
22
+
23
+ login = @response.forms.first
24
+ login['logon.username'] = username
25
+ login['logon.password'] = password
26
+
27
+ @response = @agent.submit(login)
28
+ end
29
+
30
+ def download(filename, pages, months)
31
+ @response = @agent.get URL_ORDERS
32
+
33
+ logged_in!
34
+
35
+ begin
36
+ left = pages.to_i
37
+ page = 1
38
+ stop_after = months ? (Date.today << months.to_i).strftime('%Y-%m-%d') : nil
39
+
40
+ log_info "will stop scraping after reaching #{stop_after}" unless stop_after.nil?
41
+ log_info "writing data to #{filename}"
42
+
43
+ file = File.open(filename, "w")
44
+ oldest_date_on_page = Date.today.strftime
45
+
46
+ # fake entry so when evaluating this file the calculation goes up until today
47
+ # and not to the last order.
48
+ file.write "#{oldest_date_on_page}|#{oldest_date_on_page}|0|#{ORDER_NOTE_FILE_CREATED}|\n"
49
+
50
+ loop do
51
+ print ">>> page #{page}/#{pages} "
52
+ orders do |idx, travel_date, order_date, price, note, name|
53
+ print "."
54
+
55
+ travel_date = Date.parse(travel_date).strftime
56
+ order_date = Date.parse(order_date).strftime
57
+ oldest_date_on_page = [order_date, oldest_date_on_page].min
58
+
59
+ file.write "#{travel_date}|#{order_date}|#{price}|#{note}|#{name}\n" if stop_after.nil? || travel_date >= stop_after
60
+ end
61
+ puts
62
+ next!
63
+
64
+ log_debug "oldest order on page was on #{oldest_date_on_page}" if Halffare.debug
65
+
66
+ page += 1
67
+ left -= 1
68
+
69
+ break if !stop_after.nil? && oldest_date_on_page < stop_after
70
+ break if left <= 0
71
+ end
72
+ rescue IOError => e
73
+ puts e
74
+ ensure
75
+ file.close unless file == nil
76
+ end
77
+ end
78
+
79
+ private
80
+ def orders
81
+ @response.search('#orders tbody tr').each do |order|
82
+ idx = order.attr('id').split('-')[1]
83
+ order_date = order.xpath('./td[1]').text.gsub(/[[:space:]]/, ' ').strip
84
+ travel_date = order.xpath('./td[2]').text.gsub(/[[:space:]]/, ' ').strip
85
+ price = order.xpath('./td[4]').text.gsub(/[[:space:]]/, ' ').strip
86
+ note = order.xpath('./td[5]').text.gsub(/[[:space:]]/, ' ').strip
87
+
88
+ description = ""
89
+ @response.search('#bezeichnungen tr#ordersBezeichnung-' + idx + ' td ul li').each { |i|
90
+ description << "::" unless description.empty?
91
+
92
+ description << i.to_s.gsub(/<br>.*</, '<').gsub(/[[:space:]]/, ' ').gsub(/<.*?>/, '').strip
93
+ }
94
+
95
+ yield idx, order_date, travel_date, price, note, description if block_given?
96
+ end
97
+ end
98
+
99
+ def next!
100
+ @response = @response.forms_with(:action => '/ticketshop/b2c/dossierListe.do').first
101
+ @response = @response.submit(@response.button_with(:name => 'method:more'))
102
+ end
103
+
104
+ def logged_in!
105
+ error = @response.at '.skinMessageBoxError'
106
+ if error != nil
107
+ STDERR.puts "ERROR: Not logged in, verify your username and password"
108
+ exit 1
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,16 @@
1
+ module Halffare
2
+ module Model
3
+ class Order
4
+ attr_reader :travel_date, :order_date, :price, :note, :description
5
+ def initialize(row)
6
+ from_row(row)
7
+ end
8
+
9
+ def from_row(row)
10
+ @travel_date, @order_date, @price, @note, @description = row.split("|")
11
+ @description.strip!
12
+ @price = @price.to_f
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ module Halffare
2
+ class Price
3
+ def initialize (strategy)
4
+ @strategy = strategy
5
+ end
6
+
7
+ def get(order)
8
+ @strategy.get(order)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ module Halffare
2
+ class PriceBase
3
+
4
+ # Extracts the prices from an order.
5
+ #
6
+ # @param order [Order] The order
7
+ # @return halfprice, fullprice
8
+ def get(order)
9
+ raise 'you need to implement get(order) in your price strategy'
10
+ end
11
+
12
+ def halffare=(hf)
13
+ @halffare = hf
14
+ end
15
+
16
+ private
17
+ # Which price has been paid
18
+ def price_paid
19
+ @halffare ? 'half' : 'full'
20
+ end
21
+
22
+ # Opposite of {#price_paid}
23
+ def price_paid_other
24
+ @halffare ? 'full' : 'half'
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ module Halffare
2
+ # Simple guesstimator assuming half-fare card is always half the price
3
+ # which is probably the case for non-regional tickets
4
+ class PriceGuess < PriceBase
5
+ def get(order)
6
+ price = order.price.to_f
7
+ if @halffare
8
+ return price, price*2
9
+ else
10
+ return price/2, price
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,102 @@
1
+ # encoding: binary
2
+ module Halffare
3
+ # More accurate price strategy
4
+ #
5
+ # Uses a static table for price conversions, falling back on
6
+ # guess strategy if no entry found
7
+ class PriceSbb < PriceGuess
8
+ alias :price_guess_get :get
9
+
10
+ # @param force_guess_fallback [true, false] To use `guess` strategy as fallback instead of asking the user
11
+ def initialize(force_guess_fallback = false)
12
+ @force_guess_fallback = force_guess_fallback
13
+ end
14
+
15
+ def get(order)
16
+ log_debug "looking for an existing rule..."
17
+ rules.each { |rule|
18
+ rule.each { |match, definition|
19
+ if order.description =~ /#{match}/u
20
+ case
21
+ when definition['scale'] != nil
22
+ log_debug "found scale rule: #{definition}"
23
+ return price_scale(definition, order)
24
+ when definition['set'] != nil
25
+ log_debug "found set rule: #{definition}"
26
+ return price_set(definition, order)
27
+ when definition['choices'] != nil
28
+ log_debug "found choices rules: #{definition}"
29
+ return price_choices(definition, order)
30
+ end
31
+ end
32
+ }
33
+ }
34
+ log_result "no satisfying match found" if Halffare.debug
35
+ return price_guess_get(order) if @force_guess_fallback
36
+
37
+ ask_for_price(order)
38
+ end
39
+
40
+ private
41
+ # Loads rules form the rules file.
42
+ def rules
43
+ @rules ||= YAML.load_file(File.expand_path('../pricerules_sbb.yml', __FILE__))
44
+ end
45
+
46
+ # Calculates the prices according to the `scale` definition.
47
+ def price_scale(definition, order)
48
+ return definition['scale'][price_paid][0] * order.price, definition['scale'][price_paid][1] * order.price
49
+ end
50
+
51
+ # Calculates the prices according to the `set` definition.
52
+ def price_set(definition, order)
53
+ if order.price != definition['set'][price_paid]
54
+ log_order(order)
55
+ log_error 'order matched but price differs; ignoring this order.'
56
+ p order
57
+ p definition
58
+ return 0, 0
59
+ else
60
+ return definition['set']['half'], definition['set']['full']
61
+ end
62
+ end
63
+
64
+ # Calculates the prices according to the `choices` definition.
65
+ def price_choices(definition, order)
66
+ # auto select
67
+ definition['choices'].each { |name,prices| return prices['half'], prices['full'] if prices[price_paid] == order.price }
68
+
69
+ return price_guess_get(order) if @force_guess_fallback
70
+
71
+ choose do |menu|
72
+ menu.prompt = "\nSelect the choice that applies to your travels? "
73
+
74
+ log_info "\n"
75
+ definition['choices'].each do |name,prices|
76
+ menu.choice "#{name} (half-fare: #{currency(prices['half'])}, full: #{currency(prices['full'])})" do
77
+ end
78
+ end
79
+ menu.choice "…or enter manually" do ask_for_price(order) end
80
+ end
81
+ end
82
+
83
+ # Ask the user for the price.
84
+ def ask_for_price(order)
85
+ guesshalf, guessfull = price_guess_get(order)
86
+
87
+ if !Halffare.debug
88
+ # was already logged
89
+ log_order(order)
90
+ end
91
+
92
+ if @halffare
93
+ other = ask("What would have been the full price? ", Float) { |q| q.default = guessfull }
94
+ return order.price, other
95
+ else
96
+ other = ask("What would have been the half-fare price? ", Float) { |q| q.default = guesshalf }
97
+ return other, order.price
98
+ end
99
+ end
100
+
101
+ end
102
+ end
@@ -0,0 +1,16 @@
1
+ - "Gutschrift:Erstattung":
2
+ scale: {half: [-1,-2], full: [-0.5,-1]}
3
+ - "Individual tickets":
4
+ scale: {half: [1,2], full: [0.5,1]}
5
+ - "bike pass":
6
+ scale: {half: [1,1], full: [1,1]}
7
+ - "ZVV Individual ticket/Day Pass city network Zürich \\(Zone 110\\)":
8
+ set: {half: 5.80, full: 8.40}
9
+ - "Passepartout Individual ticket LUZERN":
10
+ choices: {
11
+ "Kurzstrecke 101": {half: 2.40, full: 2.90},
12
+ "2 Zonen 101": {half: 3.20, full: 4.50}
13
+ }
14
+ - "Class upgrade \\(route-specific\\)":
15
+ scale: {half: [1,2], full: [0.5,1]}
16
+
@@ -0,0 +1,155 @@
1
+ module Halffare
2
+ class Stats
3
+
4
+ # Reads orders from `filename` that date back to max `months` months.
5
+ #
6
+ # @param filename [String] The filename to read from
7
+ # @param months [Integer, nil] Number of months look back or nil for all
8
+ def read(filename, months=nil)
9
+ @orders = []
10
+ start = months ? (Date.today << months.to_i).strftime('%Y-%m-%d') : nil
11
+ file = File.open(filename, "r:UTF-8") do |f|
12
+ while line = f.gets
13
+ order = Halffare::Model::Order.new(line)
14
+ if (start.nil? || line[0,10] >= start) && (order.note != Fetch::ORDER_NOTE_FILE_CREATED)
15
+ @orders.push(order)
16
+ end
17
+ end
18
+ end
19
+ log_info "read #{@orders.length} orders from #{filename}"
20
+ if @orders.length == 0
21
+ if start.nil?
22
+ log_notice "no orders found"
23
+ else
24
+ log_notice "no orders found after #{start}, maybe tweak the --months param"
25
+ end
26
+ end
27
+ end
28
+
29
+ # How many orders were processed.
30
+ def count
31
+ @orders.length
32
+ end
33
+
34
+ # Calculates prices according to given strategy.
35
+ #
36
+ # @param strategy [String] Strategy name
37
+ # @param halffare [true, false] True if tickets were bought with a halffare card
38
+ def calculate(strategy, halffare)
39
+ @halfprice = 0
40
+ @fullprice = 0
41
+
42
+ if halffare
43
+ log_info "assuming order prices as half-fare"
44
+ else
45
+ log_info "assuming order prices as full"
46
+ end
47
+
48
+ log_notice "please note that you are using a strategy that involves guessing the real price" if ['guess', 'sbbguess'].include? strategy
49
+
50
+ strategy = price_factory(strategy)
51
+ strategy.halffare = halffare
52
+ log_info "using price strategy: #{strategy.class}"
53
+ price = Price.new(strategy)
54
+ log_info "calculating prices..."
55
+
56
+ @date_min = false
57
+ @date_max = false
58
+ @orders.each do |order|
59
+
60
+ if Halffare.debug
61
+ log_order(order)
62
+ end
63
+
64
+ halfprice, fullprice = price.get(order)
65
+
66
+ if Halffare.debug
67
+ if halfprice != 0 && fullprice != 0
68
+ log_result "FOUND: #{order.description} (#{order.price}): half=#{currency(halfprice)}, full=#{currency(fullprice)}"
69
+
70
+ if halffare
71
+ log_emphasize "You would pay (full price): #{currency(fullprice)}, you save #{currency(fullprice - order.price)}"
72
+ else
73
+ log_emphasize "You would pay (half-fare): #{currency(halfprice)}, you pay #{currency(order.price - halfprice)} more"
74
+ end
75
+ end
76
+ end
77
+
78
+ @halfprice += halfprice
79
+ @fullprice += fullprice
80
+
81
+ @date_min = order.travel_date if !@date_min || order.travel_date < @date_min
82
+ @date_max = order.travel_date if !@date_max || order.travel_date > @date_max
83
+ end
84
+ end
85
+
86
+ # Load config file.
87
+ def config
88
+ @config ||= YAML.load_file(File.expand_path('../../../halffare.yml', __FILE__))
89
+ end
90
+
91
+ def display(halffare)
92
+ say("\n")
93
+ days = (Date.parse(@date_max) - Date.parse(@date_min)).to_i
94
+ paid = halffare ? @halfprice : @fullprice
95
+ paid_per_day = paid / days
96
+ fullprice_per_day = @fullprice / days
97
+ saved_per_day = (@fullprice - @halfprice) / days
98
+
99
+ log_info "Results"
100
+ log_info "======="
101
+ say("\n")
102
+ log_info "OVERALL"
103
+ log_info "orders: #{@orders.length}"
104
+ log_info "first travel date: #{@date_min}"
105
+ log_info "last travel date : #{@date_max} (#{days} days)"
106
+ log_info "half-fare price : #{currency(@halfprice)}#{halffare ? ' (what you paid)':''}"
107
+ log_info "full price : #{currency(@fullprice)}#{halffare ? '' : ' (what you paid)'}"
108
+ log_info "half-fare savings: #{currency(@fullprice - @halfprice)}"
109
+ say("\n")
110
+ log_info "PER DAY"
111
+ log_info "you pay : #{currency(paid_per_day)}"
112
+ log_info "half-fare savings: #{currency(saved_per_day)}"
113
+
114
+ say("\n")
115
+ log_info "Half-Fare Card"
116
+ log_info "-------------"
117
+
118
+ config['cards'].each do |months,cash|
119
+ say("\n")
120
+ years = months/12
121
+ saved = saved_per_day * years * 365
122
+ log_info "#{years} YEAR#{years == 1 ? '' : 'S'} #{currency(cash)}"
123
+ log_info "you pay : #{currency(paid_per_day * years * 365)}"
124
+ log_info "full price : #{currency(fullprice_per_day * years * 365)}"
125
+ log_info "half-fare savings: #{currency(saved)}"
126
+ if saved >= cash
127
+ say("<%= color('GOOD TO GO', WHITE, BOLD, ON_GREEN) %> (#{currency((saved - cash) / years)} net win per year)")
128
+ else
129
+ say("<%= color('NAY', WHITE, BOLD, ON_RED) %> (#{currency((cash - saved) / years)} net loss per year)")
130
+ end
131
+ end
132
+
133
+ if fullprice_per_day * 365 > 2500
134
+ say("\n")
135
+ log_result "Since your tickets would cost approximately #{currency(fullprice_per_day * 365)} per year (w/o half-fare card) you should check out the GA prices for your age bracket: http://www.sbb.ch/abos-billette/abonnemente/ga/preise.html"
136
+ end
137
+ end
138
+
139
+ private
140
+ def price_factory(strategy)
141
+ case strategy.to_s
142
+ when "guess"
143
+ Halffare::PriceGuess.new
144
+ when "sbb"
145
+ Halffare::PriceSbb.new
146
+ when "sbbguess"
147
+ Halffare::PriceSbb.new(true)
148
+ else
149
+ raise "unknown strategy: #{strategy}"
150
+ end
151
+ end
152
+
153
+ end
154
+ end
155
+
@@ -0,0 +1,11 @@
1
+ module Halffare
2
+ VERSION = '0.1.1'
3
+ @@debug = false
4
+
5
+ def self.debug=(d)
6
+ @@debug = d
7
+ end
8
+ def self.debug
9
+ @@debug
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: halffare
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Roland Schilter
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-05-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rdoc
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: aruba
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: gli
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '='
60
+ - !ruby/object:Gem::Version
61
+ version: 2.9.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '='
67
+ - !ruby/object:Gem::Version
68
+ version: 2.9.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: mechanize
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 2.7.3
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 2.7.3
83
+ - !ruby/object:Gem::Dependency
84
+ name: highline
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 1.6.21
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 1.6.21
97
+ description: Evaluates whether a SBB Half-Fare travelcard is profitable based on your
98
+ online order history
99
+ email: roli@schilter.me
100
+ executables:
101
+ - halffare
102
+ extensions: []
103
+ extra_rdoc_files:
104
+ - README.rdoc
105
+ - halffare.rdoc
106
+ files:
107
+ - bin/halffare
108
+ - lib/halffare/model/order.rb
109
+ - lib/halffare/fetch.rb
110
+ - lib/halffare/price.rb
111
+ - lib/halffare/price_base.rb
112
+ - lib/halffare/price_guess.rb
113
+ - lib/halffare/price_sbb.rb
114
+ - lib/halffare/pricerules_sbb.yml
115
+ - lib/halffare/stats.rb
116
+ - lib/halffare/version.rb
117
+ - lib/halffare.rb
118
+ - README.rdoc
119
+ - halffare.rdoc
120
+ homepage: https://github.com/rndstr/halffare
121
+ licenses:
122
+ - MIT
123
+ metadata: {}
124
+ post_install_message:
125
+ rdoc_options:
126
+ - --title
127
+ - halffare
128
+ - --main
129
+ - README.rdoc
130
+ - -ri
131
+ require_paths:
132
+ - lib
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - '>='
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - '>='
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubyforge_project:
146
+ rubygems_version: 2.0.5
147
+ signing_key:
148
+ specification_version: 4
149
+ summary: SBB Half-Fare travelcard profitability
150
+ test_files: []