sim-meter-au 0.1.0

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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimMeterAU
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class ProviderNotFoundError < ConfigurationError; end
7
+ class AuthenticationError < Error; end
8
+ class ConnectionError < Error; end
9
+ class UnexpectedResponseError < Error; end
10
+ class ServiceNotFoundError < Error; end
11
+ class AmbiguousServiceError < Error; end
12
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimMeterAU
4
+ Plan = Data.define(
5
+ :name,
6
+ :type,
7
+ :expires_on,
8
+ :days_remaining,
9
+ :auto_recharge
10
+ ) do
11
+ include Serializable
12
+ end
13
+
14
+ DataBalance = Data.define(
15
+ :plan_remaining_bytes,
16
+ :rollover_bytes,
17
+ :total_remaining_bytes
18
+ ) do
19
+ include Serializable
20
+ end
21
+
22
+ Payg = Data.define(
23
+ :balance_cents,
24
+ :expires_on,
25
+ :days_until_expiry
26
+ ) do
27
+ include Serializable
28
+ end
29
+
30
+ ServiceDetails = Data.define(
31
+ :service,
32
+ :status,
33
+ :plan,
34
+ :data,
35
+ :payg,
36
+ :roaming_enabled,
37
+ :retrieved_at
38
+ ) do
39
+ include Serializable
40
+ end
41
+
42
+ UsageSummary = Data.define(
43
+ :type,
44
+ :count,
45
+ :rounded_usage,
46
+ :charge_dollars
47
+ ) do
48
+ include Serializable
49
+ end
50
+
51
+ UsageRecord = Data.define(
52
+ :type,
53
+ :number,
54
+ :destination,
55
+ :usage,
56
+ :charge_dollars,
57
+ :date_label
58
+ ) do
59
+ include Serializable
60
+ end
61
+
62
+ UsageReport = Data.define(
63
+ :service,
64
+ :from,
65
+ :to,
66
+ :summaries,
67
+ :records
68
+ ) do
69
+ include Serializable
70
+ end
71
+
72
+ Order = Data.define(
73
+ :id,
74
+ :number,
75
+ :type,
76
+ :plan_name,
77
+ :date_label,
78
+ :total_cents,
79
+ :transaction_reference
80
+ ) do
81
+ include Serializable
82
+ end
83
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimMeterAU
4
+ class Provider
5
+ OPERATIONS = %i[
6
+ authenticate!
7
+ authenticated?
8
+ services
9
+ service
10
+ service_details
11
+ usage
12
+ orders
13
+ ].freeze
14
+
15
+ class << self
16
+ def provider_key(value = nil)
17
+ @provider_key = value.to_sym if value
18
+ @provider_key
19
+ end
20
+
21
+ def display_name(value = nil)
22
+ @display_name = value.to_s if value
23
+ @display_name || provider_key.to_s
24
+ end
25
+
26
+ def credential_names(*names)
27
+ @credential_names = names.map(&:to_sym) unless names.empty?
28
+ @credential_names || []
29
+ end
30
+
31
+ def credentials_from(environment)
32
+ prefix = provider_key.to_s.upcase
33
+ credential_names.to_h do |name|
34
+ [name, environment.fetch("#{prefix}_#{name.to_s.upcase}", "")]
35
+ end
36
+ end
37
+ end
38
+
39
+ def provider
40
+ self.class.provider_key
41
+ end
42
+
43
+ def authenticate!
44
+ unsupported_operation(__method__)
45
+ end
46
+
47
+ def login!
48
+ authenticate!
49
+ end
50
+
51
+ def authenticated?
52
+ unsupported_operation(__method__)
53
+ end
54
+
55
+ def services
56
+ unsupported_operation(__method__)
57
+ end
58
+
59
+ def service(_identifier)
60
+ unsupported_operation(__method__)
61
+ end
62
+
63
+ def service_details(_identifier)
64
+ unsupported_operation(__method__)
65
+ end
66
+
67
+ def usage(_identifier, from:, to:)
68
+ unsupported_operation(__method__)
69
+ end
70
+
71
+ def orders(_identifier)
72
+ unsupported_operation(__method__)
73
+ end
74
+
75
+ private
76
+
77
+ def unsupported_operation(operation)
78
+ raise NotImplementedError,
79
+ "#{self.class} must implement ##{operation}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimMeterAU
4
+ module Providers
5
+ class AldiMobile
6
+ module Parsers
7
+ class Orders
8
+ HEADERS = ["Order #", "Number", "Type", "Plan", "Date", "Actions"].freeze
9
+
10
+ def parse(page)
11
+ table = page.css("table").find do |candidate|
12
+ candidate.css("th").map { |heading| text(heading) } == HEADERS
13
+ end
14
+ return [] unless table
15
+
16
+ orders = []
17
+ current = nil
18
+
19
+ table.css("tbody tr").each do |row|
20
+ cells = row.css("td").map { |cell| text(cell) }
21
+ if cells.length >= 6
22
+ orders << build_order(current) if current
23
+ current = {
24
+ id: cells[0],
25
+ number: normalize_number(cells[1]),
26
+ type: cells[2],
27
+ plan_name: cells[3],
28
+ date_label: cells[4]
29
+ }
30
+ elsif current && cells.length == 1
31
+ current[:total_cents] = parse_total(cells[0])
32
+ current[:transaction_reference] = cells[0][/Transaction Ref:\s*([^\s]+)/i, 1]
33
+ end
34
+ end
35
+
36
+ orders << build_order(current) if current
37
+ orders
38
+ end
39
+
40
+ private
41
+
42
+ def build_order(attributes)
43
+ Order.new(
44
+ id: attributes[:id],
45
+ number: attributes[:number],
46
+ type: attributes[:type],
47
+ plan_name: attributes[:plan_name],
48
+ date_label: attributes[:date_label],
49
+ total_cents: attributes[:total_cents],
50
+ transaction_reference: attributes[:transaction_reference]
51
+ )
52
+ end
53
+
54
+ def parse_total(value)
55
+ amount = value[/Total Cost:\s*\$([\d,]+(?:\.\d+)?)/i, 1]
56
+ return unless amount
57
+
58
+ (amount.delete(",").to_f * 100).round
59
+ end
60
+
61
+ def normalize_number(number)
62
+ digits = number.gsub(/\D/, "")
63
+ digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
64
+ end
65
+
66
+ def text(node)
67
+ node&.text.to_s.gsub(/\s+/, " ").strip
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SimMeterAU
6
+ module Providers
7
+ class AldiMobile
8
+ module Parsers
9
+ class ServiceDetails
10
+ QUANTITY_UNITS = {
11
+ "B" => 1,
12
+ "KB" => 1024,
13
+ "MB" => 1024**2,
14
+ "GB" => 1024**3,
15
+ "TB" => 1024**4
16
+ }.freeze
17
+
18
+ def parse(page:, service:, roaming_enabled:, retrieved_at: Time.now)
19
+ plan_panel = find_panel(page, excluding: /Pay As You Go/i)
20
+ payg_panel = find_panel(page, including: /Pay As You Go/i)
21
+ plan_rows = resource_rows(plan_panel)
22
+ payg_rows = resource_rows(payg_panel)
23
+ plan = parse_plan(plan_panel, plan_rows)
24
+
25
+ SimMeterAU::ServiceDetails.new(
26
+ service:,
27
+ status: plan ? "active_plan" : "no_active_plan",
28
+ plan:,
29
+ data: parse_data(plan_rows),
30
+ payg: parse_payg(payg_rows),
31
+ roaming_enabled:,
32
+ retrieved_at:
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def find_panel(page, including: nil, excluding: nil)
39
+ page.css("#service-body .panel").find do |panel|
40
+ heading = text(panel.at_css("h3.panel-title"))
41
+ next false if heading.empty?
42
+ next false if including && !heading.match?(including)
43
+ next false if excluding && heading.match?(excluding)
44
+
45
+ true
46
+ end
47
+ end
48
+
49
+ def resource_rows(panel)
50
+ return {} unless panel
51
+
52
+ panel.css(".row.clearfix").to_h do |row|
53
+ label = text(row.at_css("label"))
54
+ value = text(row.css(".col-xs-6").last)
55
+ [label, value]
56
+ end.reject { |label, _value| label.empty? }
57
+ end
58
+
59
+ def parse_plan(panel, rows)
60
+ return unless panel
61
+
62
+ name = text(panel.at_css("h3.panel-title"))
63
+ days, expiry = parse_expiry(rows["Days remaining"])
64
+ auto_recharge = panel.at_css("#autorechargeswitch")&.key?("checked")
65
+
66
+ Plan.new(
67
+ name:,
68
+ type: plan_type(name),
69
+ expires_on: expiry,
70
+ days_remaining: days,
71
+ auto_recharge:
72
+ )
73
+ end
74
+
75
+ def parse_data(rows)
76
+ values = {
77
+ plan_remaining_bytes: parse_bytes(rows["Plan data remaining"]),
78
+ rollover_bytes: parse_bytes(rows["Data rollover"]),
79
+ total_remaining_bytes: parse_bytes(rows["Total data for you to use"])
80
+ }
81
+ return if values.values.all?(&:nil?)
82
+
83
+ DataBalance.new(**values)
84
+ end
85
+
86
+ def parse_payg(rows)
87
+ balance_label = rows.keys.find { |label| label.match?(/Pay As You Go.*balance/i) }
88
+ days, expiry = parse_expiry(rows["Days until service expiry"])
89
+ balance = parse_money(rows[balance_label]) if balance_label
90
+ return if balance.nil? && expiry.nil? && days.nil?
91
+
92
+ Payg.new(
93
+ balance_cents: balance,
94
+ expires_on: expiry,
95
+ days_until_expiry: days
96
+ )
97
+ end
98
+
99
+ def parse_expiry(value)
100
+ return [nil, nil] if value.to_s.empty?
101
+
102
+ days = value[/\A\d+/]&.to_i
103
+ date = value[/Expiry\s+(\d{2}\/\d{2}\/\d{4})/, 1]
104
+ expiry = Date.strptime(date, "%d/%m/%Y") if date
105
+ [days, expiry]
106
+ end
107
+
108
+ def parse_bytes(value)
109
+ match = /([\d,.]+)\s*(B|KB|MB|GB|TB)\b/i.match(value.to_s)
110
+ return unless match
111
+
112
+ number = match[1].delete(",").to_f
113
+ (number * QUANTITY_UNITS.fetch(match[2].upcase)).round
114
+ end
115
+
116
+ def parse_money(value)
117
+ amount = value.to_s[/\$\s*([\d,]+(?:\.\d+)?)/, 1]
118
+ return unless amount
119
+
120
+ (amount.delete(",").to_f * 100).round
121
+ end
122
+
123
+ def plan_type(name)
124
+ case name
125
+ when /Family Plan/i then "family"
126
+ when /Mobile Plan/i then "mobile"
127
+ when /Data Plan/i then "data"
128
+ when /Super Pack/i then "super_pack"
129
+ else "unknown"
130
+ end
131
+ end
132
+
133
+ def text(node)
134
+ node&.text.to_s.gsub(/\s+/, " ").strip
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimMeterAU
4
+ module Providers
5
+ class AldiMobile
6
+ module Parsers
7
+ class Usage
8
+ def parse(page:, service:, from:, to:)
9
+ UsageReport.new(
10
+ service:,
11
+ from:,
12
+ to:,
13
+ summaries: parse_summaries(page),
14
+ records: parse_records(page)
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def parse_summaries(page)
21
+ table = find_table(page, ["Usage Type", "Count", "Rounded Duration/Data", "Charge"])
22
+ return [] unless table
23
+
24
+ table.css("tbody tr").filter_map do |row|
25
+ cells = row.css("td").map { |cell| text(cell) }
26
+ next unless cells.length >= 4
27
+
28
+ UsageSummary.new(
29
+ type: cells[0],
30
+ count: cells[1].delete(",").to_i,
31
+ rounded_usage: cells[2],
32
+ charge_dollars: money_string(cells[3])
33
+ )
34
+ end
35
+ end
36
+
37
+ def parse_records(page)
38
+ table = find_table(page, ["Number", "Destination", "Duration/Data", "Charge", "Date"])
39
+ return [] unless table
40
+
41
+ current_type = nil
42
+ table.css("tbody tr").filter_map do |row|
43
+ cells = row.css("td").map { |cell| text(cell) }
44
+ if cells.length == 1
45
+ current_type = cells.first
46
+ next
47
+ end
48
+ next unless cells.length >= 5
49
+
50
+ UsageRecord.new(
51
+ type: current_type,
52
+ number: normalize_number(cells[0]),
53
+ destination: cells[1],
54
+ usage: cells[2],
55
+ charge_dollars: money_string(cells[3]),
56
+ date_label: cells[4]
57
+ )
58
+ end
59
+ end
60
+
61
+ def find_table(page, expected_headers)
62
+ page.css("table").find do |table|
63
+ table.css("th").map { |heading| text(heading) } == expected_headers
64
+ end
65
+ end
66
+
67
+ def normalize_number(number)
68
+ digits = number.gsub(/\D/, "")
69
+ digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
70
+ end
71
+
72
+ def money_string(value)
73
+ value.to_s.delete_prefix("$").delete(",")
74
+ end
75
+
76
+ def text(node)
77
+ node&.text.to_s.gsub(/\s+/, " ").strip
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mechanize"
4
+ require "openssl"
5
+ require "json"
6
+ require "date"
7
+
8
+ module SimMeterAU
9
+ module Providers
10
+ class AldiMobile < Provider
11
+ provider_key :aldi_mobile
12
+ display_name "ALDImobile"
13
+ credential_names :login, :password
14
+ DEFAULT_BASE_URL = "https://my.aldimobile.com.au"
15
+ LOGIN_PATH = "/login/"
16
+ LOGIN_FORM_ACTION = %r{/login_check/?\z}
17
+ SERVICE_SWITCH_PATH = %r{\A/admin/s/(?<id>\d+)/switch/?\z}
18
+ MOBILE_NUMBER = /(?:\+?61[\s-]?|0)4(?:[\s-]?\d){8}/
19
+
20
+ attr_reader :page
21
+
22
+ def initialize(login:, password:, base_url: DEFAULT_BASE_URL, agent: nil)
23
+ raise ConfigurationError, "login cannot be empty" if login.to_s.strip.empty?
24
+ raise ConfigurationError, "password cannot be empty" if password.to_s.empty?
25
+
26
+ @login = login.to_s
27
+ @password = password.to_s
28
+ @base_url = base_url.delete_suffix("/")
29
+ @agent = agent || build_agent
30
+ @authenticated = false
31
+ end
32
+
33
+ def authenticate!
34
+ login_page = @agent.get("#{@base_url}#{LOGIN_PATH}")
35
+ form = find_login_form(login_page)
36
+
37
+ unless form
38
+ raise UnexpectedResponseError, "ALDImobile login form was not found"
39
+ end
40
+
41
+ form["login_user[login]"] = @login
42
+ form["login_user[password]"] = @password
43
+
44
+ submit_button = form.button_with(name: "login_user[save]")
45
+ @page = submit_button ? @agent.submit(form, submit_button) : form.submit
46
+
47
+ unless authenticated_page?(@page)
48
+ @authenticated = false
49
+ raise AuthenticationError, authentication_error_message(@page)
50
+ end
51
+
52
+ @authenticated = true
53
+ self
54
+ rescue Mechanize::ResponseCodeError => error
55
+ raise ConnectionError, "ALDImobile returned HTTP #{error.response_code}"
56
+ rescue SocketError, Timeout::Error, EOFError, Errno::ECONNREFUSED,
57
+ Errno::ECONNRESET, OpenSSL::SSL::SSLError => error
58
+ raise ConnectionError, "Could not connect to ALDImobile: #{error.class}"
59
+ end
60
+
61
+ def authenticated?
62
+ @authenticated
63
+ end
64
+
65
+ def services
66
+ ensure_authenticated!
67
+
68
+ @page.links.filter_map do |link|
69
+ match = SERVICE_SWITCH_PATH.match(link.href.to_s)
70
+ next unless match
71
+
72
+ number = link.text.to_s[MOBILE_NUMBER]
73
+ next unless number
74
+
75
+ Service.new(
76
+ id: match[:id],
77
+ name: service_name(link.text, number),
78
+ number: normalize_mobile_number(number)
79
+ )
80
+ end.uniq(&:id)
81
+ end
82
+
83
+ def service(identifier)
84
+ ensure_authenticated!
85
+ return identifier if identifier.is_a?(Service)
86
+
87
+ query = identifier.to_s.strip
88
+ matches = services.select do |candidate|
89
+ candidate.id == query ||
90
+ candidate.number == normalize_identifier_number(query) ||
91
+ candidate.name.casecmp?(query)
92
+ end
93
+
94
+ if matches.empty?
95
+ raise ServiceNotFoundError, "No ALDImobile service matches #{query.inspect}"
96
+ end
97
+ if matches.length > 1
98
+ raise AmbiguousServiceError, "More than one ALDImobile service matches #{query.inspect}"
99
+ end
100
+
101
+ matches.first
102
+ end
103
+
104
+ def service_details(identifier)
105
+ selected_service = service(identifier)
106
+ overview = request { @agent.get("#{@base_url}/admin/s/#{selected_service.id}/switch") }
107
+ roaming_enabled = roaming_enabled?(selected_service)
108
+
109
+ Parsers::ServiceDetails.new.parse(
110
+ page: overview,
111
+ service: selected_service,
112
+ roaming_enabled:
113
+ )
114
+ end
115
+
116
+ def usage(identifier, from: Date.today - 30, to: Date.today)
117
+ selected_service = service(identifier)
118
+ from = coerce_date(from)
119
+ to = coerce_date(to)
120
+ raise ConfigurationError, "usage start date must not be after end date" if from > to
121
+
122
+ page = request { @agent.get("#{@base_url}/admin/s/#{selected_service.id}/usage") }
123
+ form = page.forms.find { |candidate| candidate.action.to_s.end_with?("/usage") }
124
+ unless form
125
+ raise UnexpectedResponseError, "ALDImobile usage form was not found"
126
+ end
127
+
128
+ form["usage[start]"] = from.iso8601
129
+ form["usage[end]"] = to.iso8601
130
+ submit_button = form.button_with(name: "usage[getusage]")
131
+ result = request { submit_button ? @agent.submit(form, submit_button) : form.submit }
132
+
133
+ Parsers::Usage.new.parse(page: result, service: selected_service, from:, to:)
134
+ end
135
+
136
+ def orders(identifier)
137
+ selected_service = service(identifier)
138
+ page = request { @agent.get("#{@base_url}/admin/s/#{selected_service.id}/orders") }
139
+ Parsers::Orders.new.parse(page)
140
+ end
141
+
142
+ private
143
+
144
+ def ensure_authenticated!
145
+ raise AuthenticationError, "Log in before requesting services" unless authenticated?
146
+ end
147
+
148
+ def roaming_enabled?(selected_service)
149
+ response = request do
150
+ @agent.get("#{@base_url}/admin/roaming/status/#{selected_service.number}")
151
+ end
152
+ status = JSON.parse(response.body).fetch("roaming_status", nil)
153
+ return true if status.to_s == "1"
154
+ return false if status.to_s == "0"
155
+
156
+ nil
157
+ rescue JSON::ParserError
158
+ nil
159
+ end
160
+
161
+ def request
162
+ yield
163
+ rescue Mechanize::ResponseCodeError => error
164
+ raise ConnectionError, "ALDImobile returned HTTP #{error.response_code}"
165
+ rescue SocketError, Timeout::Error, EOFError, Errno::ECONNREFUSED,
166
+ Errno::ECONNRESET, OpenSSL::SSL::SSLError => error
167
+ raise ConnectionError, "Could not connect to ALDImobile: #{error.class}"
168
+ end
169
+
170
+ def coerce_date(value)
171
+ return value if value.is_a?(Date)
172
+
173
+ Date.iso8601(value.to_s)
174
+ rescue Date::Error
175
+ raise ConfigurationError, "invalid ISO date: #{value.inspect}"
176
+ end
177
+
178
+ def build_agent
179
+ Mechanize.new.tap do |agent|
180
+ agent.user_agent = "sim_meter_au/#{VERSION} aldi_mobile"
181
+ agent.open_timeout = 10
182
+ agent.read_timeout = 30
183
+ end
184
+ end
185
+
186
+ def find_login_form(login_page)
187
+ return unless login_page.respond_to?(:form_with)
188
+
189
+ login_page.form_with(id: "login-specific") ||
190
+ login_page.form_with(action: LOGIN_FORM_ACTION)
191
+ end
192
+
193
+ def authenticated_page?(response_page)
194
+ path = response_page.uri.path
195
+ on_account_page = path == "/admin" || path.start_with?("/admin/")
196
+ has_login_form = !find_login_form(response_page).nil?
197
+ has_logout_link = !response_page.link_with(href: %r{/logout/?\z}).nil?
198
+
199
+ !has_login_form && (on_account_page || has_logout_link)
200
+ end
201
+
202
+ def authentication_error_message(response_page)
203
+ error_element = response_page.at(
204
+ ".alert-danger, .form-error-message, .error, [role='alert']"
205
+ )
206
+ message = error_element&.text.to_s.gsub(/\s+/, " ").strip
207
+
208
+ message.empty? ? "ALDImobile login failed" : message
209
+ end
210
+
211
+ def service_name(link_text, number)
212
+ link_text.to_s.sub(number, "").sub(/\s*-\s*\z/, "").strip
213
+ end
214
+
215
+ def normalize_mobile_number(number)
216
+ digits = number.gsub(/\D/, "")
217
+ digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
218
+ end
219
+
220
+ def normalize_identifier_number(identifier)
221
+ digits = identifier.gsub(/\D/, "")
222
+ return "" if digits.length < 9
223
+
224
+ digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
225
+ end
226
+ end
227
+ end
228
+ end