mfynab 0.1.4 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a71ca45747525d78d9d23d653968c82ce28a715645e229be2697de76aeffb474
4
- data.tar.gz: 4eae7448677c630ec6ef0871ac9ec98253a356aebf7292a54059bc781d098ca4
3
+ metadata.gz: 1d0ef3ce6051c500eb5a269c3763ff18ae129fbbef15693eb8413fa6e3c788ef
4
+ data.tar.gz: d9788544bc9aaa518c8ad47d3abfe526fb8686fe676b49fe30358930e91dca75
5
5
  SHA512:
6
- metadata.gz: 0bb90df5c776bf130ec6efcb26082b8bb9dfa94463fd96e92da4c319392060cfd9f20d555e6b5598025f6952893044e331030e86a8d4817afd8186c87775e301
7
- data.tar.gz: 17500b0d34590b0fa4b6689d5fc9e30b56620c0a7fb4cd036c7cf995e6db90fd2b1bc0319d66e6ba1b12d07e97bd07af3d7160557eda047c9cade107d2841c66
6
+ metadata.gz: 7a6ae839f41a31c0615561ebf08c57df799ff576ef4cfde32e30eab5373e8289ca053887c272c8c2d0bc44c0643b29a41fe45a54e6f278514b6e05eecb6c8816
7
+ data.tar.gz: 7dd19ed30d13acaef9ed75649098611f36af8c19f314c4366e829e587c67a2f057ad88e9028c8058c66263b72384ff1e8cc481a48725a3e9924d7687b5edfe74
data/exe/mfynab CHANGED
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "mfynab/cli"
5
5
 
6
- CLI.start(ARGV)
6
+ MFYNAB::CLI.start(ARGV)
data/lib/mfynab/cli.rb CHANGED
@@ -2,91 +2,100 @@
2
2
 
3
3
  require "yaml"
4
4
  require "mfynab/money_forward"
5
+ require "mfynab/money_forward/session"
5
6
  require "mfynab/money_forward_data"
6
7
  require "mfynab/ynab_transaction_importer"
7
8
 
8
- class CLI
9
- def self.start(argv)
10
- new(argv).start
11
- end
9
+ module MFYNAB
10
+ class CLI
11
+ def self.start(argv)
12
+ new(argv).start
13
+ end
12
14
 
13
- def initialize(argv)
14
- @argv = argv
15
- end
15
+ def initialize(argv)
16
+ @argv = argv
17
+ end
16
18
 
17
- def start
18
- logger.info("Running...")
19
+ def start
20
+ logger.info("Running...")
19
21
 
20
- Dir.mktmpdir("mfynab") do |save_path|
21
- money_forward.download_csv(
22
- session_id: session_id,
23
- path: save_path,
24
- months: months_to_sync,
25
- )
22
+ money_forward.update_accounts(money_forward_account_names)
23
+
24
+ Dir.mktmpdir("mfynab") do |save_path|
25
+ money_forward.download_csv(
26
+ path: save_path,
27
+ months: months_to_sync,
28
+ )
29
+
30
+ data = MoneyForwardData.new(logger: logger)
31
+ data.read_all_csv(save_path)
32
+ ynab_transaction_importer.run(data.to_h)
33
+ end
26
34
 
27
- data = MFYNAB::MoneyForwardData.new(logger: logger)
28
- data.read_all_csv(save_path)
29
- ynab_transaction_importer.run(data.to_h)
35
+ logger.info("Done!")
30
36
  end
31
37
 
32
- logger.info("Done!")
33
- end
38
+ private
34
39
 
35
- private
40
+ attr_reader :argv
36
41
 
37
- attr_reader :argv
42
+ def money_forward_account_names
43
+ @_money_forward_account_names = config["accounts"].map { _1["money_forward_name"] }
44
+ end
38
45
 
39
- def session_id
40
- @_session_id ||= money_forward.get_session_id(
41
- username: config["moneyforward_username"],
42
- password: config["moneyforward_password"],
43
- )
44
- end
46
+ def ynab_transaction_importer
47
+ @_ynab_transaction_importer ||= YnabTransactionImporter.new(
48
+ config["ynab_access_token"],
49
+ config["ynab_budget"],
50
+ config["accounts"],
51
+ logger: logger,
52
+ )
53
+ end
45
54
 
46
- def ynab_transaction_importer
47
- @_ynab_transaction_importer ||= MFYNAB::YnabTransactionImporter.new(
48
- config["ynab_access_token"],
49
- config["ynab_budget"],
50
- config["accounts"],
51
- logger: logger,
52
- )
53
- end
55
+ def months_to_sync
56
+ config.fetch("months_to_sync", 3)
57
+ end
54
58
 
55
- def months_to_sync
56
- config.fetch("months_to_sync", 3)
57
- end
59
+ def money_forward
60
+ @_money_forward ||= MoneyForward.new(session, logger: logger)
61
+ end
58
62
 
59
- def money_forward
60
- @_money_forward ||= MFYNAB::MoneyForward.new(logger: logger)
61
- end
63
+ def session
64
+ @_session ||= MoneyForward::Session.new(
65
+ username: config["moneyforward_username"],
66
+ password: config["moneyforward_password"],
67
+ logger: logger,
68
+ )
69
+ end
62
70
 
63
- def config_file
64
- raise "You need to pass a config file" if argv.empty?
71
+ def config_file
72
+ raise "You need to pass a config file" if argv.empty?
65
73
 
66
- argv[0]
67
- end
74
+ argv[0]
75
+ end
68
76
 
69
- def config
70
- @_config ||= YAML
71
- .load_file(config_file)
72
- .values
73
- .first
74
- .merge(
75
- "ynab_access_token" => ENV.fetch("YNAB_ACCESS_TOKEN"),
76
- "moneyforward_username" => ENV.fetch("MONEYFORWARD_USERNAME"),
77
- "moneyforward_password" => ENV.fetch("MONEYFORWARD_PASSWORD"),
78
- )
79
- end
77
+ def config
78
+ @_config ||= YAML
79
+ .load_file(config_file)
80
+ .values
81
+ .first
82
+ .merge(
83
+ "ynab_access_token" => ENV.fetch("YNAB_ACCESS_TOKEN"),
84
+ "moneyforward_username" => ENV.fetch("MONEYFORWARD_USERNAME"),
85
+ "moneyforward_password" => ENV.fetch("MONEYFORWARD_PASSWORD"),
86
+ )
87
+ end
80
88
 
81
- def logger
82
- @_logger ||= Logger.new($stdout, level: logger_level)
83
- end
89
+ def logger
90
+ @_logger ||= Logger.new($stdout, level: logger_level)
91
+ end
84
92
 
85
- def logger_level
86
- if ENV.fetch("DEBUG", nil)
87
- Logger::DEBUG
88
- else
89
- Logger::INFO
93
+ def logger_level
94
+ if ENV.fetch("DEBUG", nil)
95
+ Logger::DEBUG
96
+ else
97
+ Logger::INFO
98
+ end
90
99
  end
91
- end
100
+ end
92
101
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ferrum"
4
+ require "net/http"
5
+ require "nokogiri"
6
+ require "uri"
7
+
8
+ module MFYNAB
9
+ class MoneyForward
10
+ class AccountStatus
11
+ class Fetcher
12
+ def initialize(session, logger:)
13
+ @logger = logger
14
+ @session = session
15
+ end
16
+
17
+ def fetch(account_names:)
18
+ logger.info("Checking Money Forward accounts status")
19
+ html = session.http_get("/accounts")
20
+ table_rows_selector = "section.accounts section.common-account-table-container > table > tr[id]"
21
+ Nokogiri::HTML(html).css(table_rows_selector).filter_map do |node|
22
+ account = parse_html_table_row(node)
23
+ next unless account_names.include?(account.name)
24
+
25
+ status_data = determine_status_data(account)
26
+ account.assign_status_data(**status_data)
27
+
28
+ account
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :session, :logger
35
+
36
+ def determine_status_data(account)
37
+ case account.raw_status
38
+ when "正常" then { key: :success }
39
+ when "更新中" then { key: :processing }
40
+ else
41
+ body = session.http_get("/accounts/polling/#{account.id_hash}")
42
+ # JSON format:
43
+ # - loading: boolean
44
+ # - message: Japanese text
45
+ # - status: success, processing, additional_request, errors, important_announcement,
46
+ # invalid_password, login, suspended
47
+ # FIXME: make more robust
48
+ queried_status = JSON.parse(body)
49
+ {
50
+ key: queried_status["status"].to_sym,
51
+ message: queried_status["message"],
52
+ }
53
+ end
54
+ end
55
+
56
+ def parse_html_table_row(node)
57
+ AccountStatus.new(
58
+ id_hash: node[:id],
59
+ name: node.xpath("td").first.text.lines[1].strip,
60
+ raw_status: node.css("td.account-status>span:not([id*='hidden'])").text.strip,
61
+ # FIXME: can this be a little more robust?
62
+ # - this should not depend on the timezone the server is running in
63
+ # - this should explicitly fail if parsing is not possible
64
+ # - what if it's January 1st and last update was last year?
65
+ updated_at: Time.parse(node.css("td.created").text[/(?<=\().*?(?=\))/]),
66
+ )
67
+ end
68
+ end
69
+
70
+ FRESHNESS_LIMIT = 24 * 60 * 60 # 1 day
71
+
72
+ attr_reader :id_hash, :name, :raw_status, :updated_at, :key, :message
73
+
74
+ def initialize(id_hash:, name:, raw_status:, updated_at:)
75
+ @id_hash = id_hash
76
+ @name = name
77
+ @raw_status = raw_status
78
+ @updated_at = updated_at
79
+ end
80
+
81
+ def assign_status_data(key:, message: nil)
82
+ @key = key
83
+ @message = message
84
+ end
85
+
86
+ def invalid_state_warning
87
+ warning = "The Money Forward account named #{name} is in an invalid state: #{key}"
88
+ warning << " (#{message})" if message
89
+ warning << ". Please handle the issue manually to resume syncing."
90
+ end
91
+
92
+ def outdated?
93
+ # Require accounts to have been updated in the last day
94
+ # FIXME: make configurable?
95
+ updated_at < Time.now - FRESHNESS_LIMIT
96
+ end
97
+
98
+ def should_update?(update_invalid:)
99
+ (key == :success && outdated?) ||
100
+ (update_invalid && failed_state?)
101
+ end
102
+
103
+ def failed_state?
104
+ !%i[success processing].include?(key)
105
+ end
106
+
107
+ def trigger_update(session)
108
+ session.http_post(
109
+ "/faggregation_queue2/#{id_hash}",
110
+ URI.encode_www_form(commit: "更新"),
111
+ "X-CSRF-Token" => session.csrf_token,
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ferrum"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module MFYNAB
8
+ class MoneyForward
9
+ class Session
10
+ COOKIE_NAME = "_moneybook_session"
11
+ DEFAULT_BASE_URL = "https://moneyforward.com"
12
+ SIGNIN_PATH = "/sign_in"
13
+
14
+ def initialize(username:, password:, logger:, base_url: DEFAULT_BASE_URL)
15
+ @username = username
16
+ @password = password
17
+ @logger = logger
18
+ @base_url = URI(base_url)
19
+ end
20
+
21
+ def login
22
+ logger.info("Logging in to Money Forward...")
23
+ with_ferrum do |browser|
24
+ submit_login_form(browser)
25
+
26
+ self.cookie = browser.cookies[COOKIE_NAME]
27
+ rescue Timeout::Error
28
+ # FIXME: use custom error class
29
+ raise "Login failed"
30
+ end
31
+ end
32
+
33
+ def cookie
34
+ @cookie || login
35
+ end
36
+
37
+ def http_get(path, params = {})
38
+ path = URI.join(base_url, path)
39
+ path.query = URI.encode_www_form(params) unless params.empty?
40
+ request = Net::HTTP::Get.new(path, "Cookie" => "#{COOKIE_NAME}=#{cookie.value}")
41
+ http_request(request)
42
+ end
43
+
44
+ def http_post(path, body, headers = {})
45
+ request = Net::HTTP::Post.new(
46
+ path,
47
+ "Cookie" => "#{COOKIE_NAME}=#{cookie.value}",
48
+ **headers,
49
+ )
50
+ request.body = body
51
+ http_request(request)
52
+ end
53
+
54
+ # FIXME: not sure a CSRF token is generic to the session
55
+ # Maybe it has different instances depending on the page it's on?
56
+ def csrf_token
57
+ @_csrf_token ||= Nokogiri::HTML(http_get("/accounts"))
58
+ .at_css("meta[name='csrf-token']")[:content]
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :username, :password, :logger, :base_url
64
+ attr_writer :cookie
65
+
66
+ def http_request(request)
67
+ # FIXME: switch to Faraday or another advanced HTTP library?
68
+ # (Better error handling, response parsing, cookies, possibly keep the connection open, etc.)
69
+ Net::HTTP.start(base_url.host, use_ssl: true) do |http|
70
+ result = http.request(request)
71
+ raise "Got unexpected result: #{result.inspect}" unless result.is_a?(Net::HTTPSuccess)
72
+
73
+ result.body
74
+ end
75
+ end
76
+
77
+ def with_ferrum
78
+ browser = Ferrum::Browser.new(
79
+ timeout: 30,
80
+ process_timeout: 20, # 10s was not always enough on CI (FIXME: make configurable per env)
81
+ headless: !ENV.key?("NO_HEADLESS"),
82
+ )
83
+ user_agent = browser.default_user_agent.sub("HeadlessChrome", "Chrome")
84
+ browser.headers.add({
85
+ "Accept-Language" => "en-US,en",
86
+ "User-Agent" => user_agent,
87
+ })
88
+ yield browser
89
+ rescue StandardError
90
+ # FIXME: add datetime to path
91
+ browser&.screenshot(path: "screenshot.png")
92
+ logger.error("An error occurred and a screenshot was saved to ./screenshot.png")
93
+ raise
94
+ ensure
95
+ browser&.quit
96
+ end
97
+
98
+ def submit_login_form(browser)
99
+ browser.goto("#{base_url}#{SIGNIN_PATH}")
100
+ browser.at_css("input[type='email']").focus.type(username)
101
+ browser.at_css("input[type='password']").focus.type(password, :Enter)
102
+ Timeout.timeout(5) do
103
+ sleep 0.1 until browser.body.include?("ログアウト")
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -1,95 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ferrum"
3
+ require "nokogiri"
4
+ require "mfynab/money_forward/account_status"
4
5
 
5
6
  module MFYNAB
6
7
  class MoneyForward
7
- DEFAULT_BASE_URL = "https://moneyforward.com"
8
- SIGNIN_PATH = "/sign_in"
9
8
  CSV_PATH = "/cf/csv"
10
- SESSION_COOKIE_NAME = "_moneybook_session"
11
- USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " \
12
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
13
9
 
14
- def initialize(logger:, base_url: DEFAULT_BASE_URL)
15
- @base_url = URI(base_url)
10
+ def initialize(session, logger:)
11
+ @session = session
16
12
  @logger = logger
17
13
  end
18
14
 
19
- def get_session_id(username:, password:)
20
- with_ferrum do |browser|
21
- browser.goto("#{base_url}#{SIGNIN_PATH}")
22
- browser.at_css("input[name='mfid_user[email]']").focus.type(username)
23
- browser.at_css("input[name='mfid_user[password]']").focus.type(password)
24
- browser.at_css("button#submitto").click
15
+ def update_accounts(account_names, update_invalid: true)
16
+ account_statuses = AccountStatus::Fetcher.new(session, logger: logger).fetch(account_names: account_names)
25
17
 
26
- # FIXME: use custom error class
27
- raise "Login failed" unless browser.cookies[SESSION_COOKIE_NAME]
18
+ finished = account_statuses.map do |account_status|
19
+ ensure_account_updated(account_status, update_invalid: update_invalid)
20
+ end.all?
28
21
 
29
- browser.cookies[SESSION_COOKIE_NAME].value
30
- end
22
+ return if finished
23
+
24
+ logger.info("Waiting for a while before checking status again...")
25
+ # FIXME: I'm never comfortable with using sleep().
26
+ # Do I want to implement a solution based on callbacks?
27
+ # For example, the script could say:
28
+ # > Accounts are out of date.
29
+ # > I triggered the updates, and will call myself again in X seconds/minutes.
30
+ # > When the accounts are updated, I'll proceed to the next step.
31
+ sleep(5)
32
+ update_accounts(account_names, update_invalid: false)
31
33
  end
32
34
 
33
- def download_csv(session_id:, path:, months:)
35
+ def download_csv(path:, months:)
34
36
  month = Date.today
35
37
  month -= month.day - 1 # First day of month
38
+ months.times do
39
+ date_string = month.strftime("%Y-%m")
36
40
 
37
- Net::HTTP.start(base_url.host, use_ssl: true) do |http|
38
- http.response_body_encoding = Encoding::SJIS
39
-
40
- months.times do
41
- date_string = month.strftime("%Y-%m")
42
-
43
- logger.info("Downloading CSV for #{date_string}")
41
+ logger.info("Downloading CSV for #{date_string}")
44
42
 
45
- # FIXME: I don't really need to save the CSV files to disk anymore.
46
- # Maybe just return parsed CSV data?
47
- File.open(File.join(path, "#{date_string}.csv"), "wb") do |file|
48
- file << download_csv_string(date: month, session_id: session_id)
49
- end
50
-
51
- month = month.prev_month
43
+ # FIXME: I don't really need to save the CSV files to disk anymore.
44
+ # Maybe just return parsed CSV data?
45
+ File.open(File.join(path, "#{date_string}.csv"), "wb") do |file|
46
+ file << download_csv_string(date: month)
52
47
  end
48
+
49
+ month = month.prev_month
53
50
  end
54
51
  end
55
52
 
56
- def download_csv_string(date:, session_id:)
57
- Net::HTTP.start(base_url.host, use_ssl: true) do |http|
58
- http.response_body_encoding = Encoding::SJIS
59
-
60
- request = Net::HTTP::Get.new(
61
- "#{CSV_PATH}?from=#{date.strftime('%Y/%m/%d')}",
62
- {
63
- "Cookie" => "#{SESSION_COOKIE_NAME}=#{session_id}",
64
- "User-Agent" => USER_AGENT,
65
- },
66
- )
67
-
68
- result = http.request(request)
69
- raise unless result.is_a?(Net::HTTPSuccess)
70
- raise unless result.body.valid_encoding?
71
-
72
- result.body.encode(Encoding::UTF_8)
73
- end
53
+ # FIXME: make private or inline
54
+ def download_csv_string(date:)
55
+ # FIXME: handle errors/edge cases
56
+ session
57
+ .http_get(CSV_PATH, from: date.strftime("%Y/%m/%d"))
58
+ .force_encoding(Encoding::SJIS)
59
+ .encode(Encoding::UTF_8)
74
60
  end
75
61
 
76
62
  private
77
63
 
78
- attr_reader :base_url, :logger
79
-
80
- def with_ferrum
81
- browser = Ferrum::Browser.new(timeout: 30, headless: !ENV.key?("NO_HEADLESS"))
82
- browser.headers.add({
83
- "Accept-Language" => "en-US,en",
84
- "User-Agent" => USER_AGENT,
85
- })
86
- yield browser
87
- rescue StandardError
88
- browser.screenshot(path: "screenshot.png")
89
- logger.error("An error occurred and a screenshot was saved to ./screenshot.png")
90
- raise
91
- ensure
92
- browser&.quit
64
+ attr_reader :session, :logger
65
+
66
+ def ensure_account_updated(account_status, update_invalid:)
67
+ if account_status.should_update?(update_invalid: update_invalid)
68
+ suffix = "Will update"
69
+ account_status.trigger_update(session)
70
+ updated = false
71
+ elsif account_status.key == :processing
72
+ suffix = "Updating"
73
+ updated = false
74
+ elsif account_status.key == :success
75
+ suffix = "Up to date"
76
+ updated = true
77
+ else
78
+ # FIXME: we can probably handle :additional_request and/or :login right here in the script,
79
+ # if we can find the time.
80
+ # FIXME: sometimes, nothing can be done about the status, for example, I can currently see モバイルSUICA
81
+ # show an `errors` status with this message:
82
+ # ただいま大変混み合っております。今しばらくお待ちいただきますようお願いいたします。 失敗日時:02/22 14:03
83
+ # In other words: "please wait". Maybe improve messaging?
84
+ suffix = "Ignoring"
85
+ updated = true
86
+ logger.warn(account_status.invalid_state_warning)
87
+ end
88
+
89
+ # FIXME: I'll probably want to refresh the display instead of relogging everything every 5 seconds.
90
+ # FIXME: would be nice to tabulate the info better, for readability.
91
+ logger.info("#{account_status.name}:\t#{account_status.key} (#{account_status.updated_at}) - #{suffix}")
92
+
93
+ updated
93
94
  end
94
95
  end
95
96
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MFYNAB
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -4,6 +4,11 @@ require "ynab"
4
4
 
5
5
  module MFYNAB
6
6
  class YnabTransactionImporter
7
+ # See https://github.com/ynab/ynab-sdk-ruby/issues/77
8
+ MEMO_MAX_LENGTH = 500
9
+ PAYEE_MAX_LENGTH = 200
10
+ IMPORT_ID_MAX_LENGTH = 36
11
+
7
12
  def initialize(api_key, budget_name, account_mappings, logger:)
8
13
  @api_key = api_key
9
14
  @budget_name = budget_name
@@ -42,7 +47,7 @@ module MFYNAB
42
47
  {
43
48
  account_id: account.id,
44
49
  amount: row["amount"] * 1_000,
45
- payee_name: row["content"][0, 100],
50
+ payee_name: row["content"][0, PAYEE_MAX_LENGTH],
46
51
  date: Date.strptime(row["date"], "%Y/%m/%d").strftime("%Y-%m-%d"),
47
52
  cleared: "cleared",
48
53
  memo: generate_memo_for(row),
@@ -65,9 +70,7 @@ module MFYNAB
65
70
  memo_parts
66
71
  .delete_if { _1.nil? || _1.empty? }
67
72
  .join(" - ")
68
- .slice(0, 200) # YNAB's API currently limits memo to 200 characters,
69
- # even though YNAB itself allows longer memos. See:
70
- # https://github.com/ynab/ynab-sdk-ruby/issues/77
73
+ .slice(0, MEMO_MAX_LENGTH)
71
74
  end
72
75
 
73
76
  # ⚠️ Be very careful when changing this method!
@@ -91,16 +94,14 @@ module MFYNAB
91
94
  # but changing it now would require a lot of work in preventing import
92
95
  # duplicates due to inconsistent import_id.
93
96
  prefix = "MFBY:v1:"
94
-
95
- max_length = 36 # YNAB API limit
96
- id_max_length = 28 # this leaves 8 characters for the prefix
97
+ digest_max_length = IMPORT_ID_MAX_LENGTH - prefix.length
97
98
 
98
99
  id = row["id"]
99
100
 
100
101
  # Only hash if the ID would exceed YNAB's limit.
101
102
  # This improves backwards compatibility with old import_ids.
102
- if prefix.length + id.length > max_length
103
- id = Digest::SHA256.hexdigest(id)[0, id_max_length]
103
+ if prefix.length + id.length > IMPORT_ID_MAX_LENGTH
104
+ id = Digest::SHA256.hexdigest(id)[0, digest_max_length]
104
105
  end
105
106
 
106
107
  prefix + id
@@ -118,12 +119,17 @@ module MFYNAB
118
119
 
119
120
  ynab_account_name = matching_mapping["ynab_name"]
120
121
 
121
- ynab_account = accounts.find do |account|
122
+ ynab_accounts = accounts.select do |account|
122
123
  account.name.include?(ynab_account_name)
123
124
  end
124
- raise "No YNAB account found with name #{ynab_account_name}." unless ynab_account
125
125
 
126
- ynab_account
126
+ if ynab_accounts.empty?
127
+ raise "No YNAB account found with name #{ynab_account_name}."
128
+ elsif ynab_accounts.size > 1
129
+ raise "Found multiple YNAB accounts matching name #{ynab_account_name}."
130
+ end
131
+
132
+ ynab_accounts.first
127
133
  end
128
134
 
129
135
  def ynab_transactions_api
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mfynab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Stosik
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-25 00:00:00.000000000 Z
11
+ date: 2025-03-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: csv
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.15'
41
+ - !ruby/object:Gem::Dependency
42
+ name: nokogiri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.18'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.18'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: psych
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -58,14 +72,14 @@ dependencies:
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: '3.4'
75
+ version: '3.6'
62
76
  type: :runtime
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: '3.4'
82
+ version: '3.6'
69
83
  description: Sync transaction history from MoneyForward to YNAB.
70
84
  email:
71
85
  - david.stosik+git-noreply@gmail.com
@@ -77,6 +91,8 @@ files:
77
91
  - exe/mfynab
78
92
  - lib/mfynab/cli.rb
79
93
  - lib/mfynab/money_forward.rb
94
+ - lib/mfynab/money_forward/account_status.rb
95
+ - lib/mfynab/money_forward/session.rb
80
96
  - lib/mfynab/money_forward_data.rb
81
97
  - lib/mfynab/version.rb
82
98
  - lib/mfynab/ynab_transaction_importer.rb
@@ -103,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
119
  - !ruby/object:Gem::Version
104
120
  version: '0'
105
121
  requirements: []
106
- rubygems_version: 3.5.11
122
+ rubygems_version: 3.5.22
107
123
  signing_key:
108
124
  specification_version: 4
109
125
  summary: Sync transaction history from MoneyForward to YNAB.