mfynab 0.1.3 → 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: df7f640ee7b19163bf41b79a560d4de67f8892d797aaaa8b06ab6ba321b4f1b6
4
- data.tar.gz: 99fcfc93e353bcc951e14505220dc626ed51230d1e0489cb0a90fc4f4d9fc5f1
3
+ metadata.gz: 1d0ef3ce6051c500eb5a269c3763ff18ae129fbbef15693eb8413fa6e3c788ef
4
+ data.tar.gz: d9788544bc9aaa518c8ad47d3abfe526fb8686fe676b49fe30358930e91dca75
5
5
  SHA512:
6
- metadata.gz: 7a7dc9f3cba490a05b552209b1b496398ab754f6f0d1ce289894780de4fb2bf7eab5bbd1d580e7bddc9230139a442d302a8849754a3f328860e7fa73339810a5
7
- data.tar.gz: 790422bd20f6e40c6ddca4471b2bcdc9ba2e948a50229e5b93d3f28cf5f5b60bb2946335d9e3058af5846137dd9d58f40dfe2a6a14d395bdb526a34151f679a5
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,88 +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: config["months_to_sync"],
25
- )
22
+ money_forward.update_accounts(money_forward_account_names)
26
23
 
27
- data = MFYNAB::MoneyForwardData.new(logger: logger)
28
- data.read_all_csv(save_path)
29
- ynab_transaction_importer.run(data.to_h)
30
- end
24
+ Dir.mktmpdir("mfynab") do |save_path|
25
+ money_forward.download_csv(
26
+ path: save_path,
27
+ months: months_to_sync,
28
+ )
31
29
 
32
- logger.info("Done!")
33
- end
30
+ data = MoneyForwardData.new(logger: logger)
31
+ data.read_all_csv(save_path)
32
+ ynab_transaction_importer.run(data.to_h)
33
+ end
34
34
 
35
- private
35
+ logger.info("Done!")
36
+ end
36
37
 
37
- attr_reader :argv
38
+ private
38
39
 
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
40
+ attr_reader :argv
45
41
 
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
42
+ def money_forward_account_names
43
+ @_money_forward_account_names = config["accounts"].map { _1["money_forward_name"] }
44
+ end
54
45
 
55
- def money_forward
56
- @_money_forward ||= MFYNAB::MoneyForward.new(logger: logger)
57
- 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
58
54
 
59
- def config_file
60
- raise "You need to pass a config file" if argv.empty?
55
+ def months_to_sync
56
+ config.fetch("months_to_sync", 3)
57
+ end
61
58
 
62
- argv[0]
63
- end
59
+ def money_forward
60
+ @_money_forward ||= MoneyForward.new(session, logger: logger)
61
+ end
64
62
 
65
- def config
66
- @_config ||= YAML
67
- .load_file(config_file)
68
- .values
69
- .first
70
- .merge(
71
- "months_to_sync" => 3,
72
- "ynab_access_token" => ENV.fetch("YNAB_ACCESS_TOKEN"),
73
- "moneyforward_username" => ENV.fetch("MONEYFORWARD_USERNAME"),
74
- "moneyforward_password" => ENV.fetch("MONEYFORWARD_PASSWORD"),
63
+ def session
64
+ @_session ||= MoneyForward::Session.new(
65
+ username: config["moneyforward_username"],
66
+ password: config["moneyforward_password"],
67
+ logger: logger,
75
68
  )
76
- end
69
+ end
77
70
 
78
- def logger
79
- @_logger ||= Logger.new($stdout, level: logger_level)
80
- end
71
+ def config_file
72
+ raise "You need to pass a config file" if argv.empty?
81
73
 
82
- def logger_level
83
- if ENV.fetch("DEBUG", nil)
84
- Logger::DEBUG
85
- else
86
- Logger::INFO
74
+ argv[0]
87
75
  end
88
- end
76
+
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
88
+
89
+ def logger
90
+ @_logger ||= Logger.new($stdout, level: logger_level)
91
+ end
92
+
93
+ def logger_level
94
+ if ENV.fetch("DEBUG", nil)
95
+ Logger::DEBUG
96
+ else
97
+ Logger::INFO
98
+ end
99
+ end
100
+ end
89
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.3"
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
@@ -22,7 +27,12 @@ module MFYNAB
22
27
 
23
28
  begin
24
29
  logger.info("Importing #{transactions.size} transactions for #{account.name}")
25
- ynab_transactions_api.create_transaction(budget.id, transactions: transactions)
30
+ result = ynab_transactions_api.create_transaction(budget.id, transactions: transactions)
31
+ logger.info(
32
+ "Imported #{result.data.transactions.size} transactions " \
33
+ "for #{account.name} " \
34
+ "(#{result.data.duplicate_import_ids.size} duplicates)",
35
+ )
26
36
  rescue StandardError => e
27
37
  logger.error("Error importing transactions for #{budget.name}. #{e} : #{e.detail}")
28
38
  end
@@ -37,7 +47,7 @@ module MFYNAB
37
47
  {
38
48
  account_id: account.id,
39
49
  amount: row["amount"] * 1_000,
40
- payee_name: row["content"][0, 100],
50
+ payee_name: row["content"][0, PAYEE_MAX_LENGTH],
41
51
  date: Date.strptime(row["date"], "%Y/%m/%d").strftime("%Y-%m-%d"),
42
52
  cleared: "cleared",
43
53
  memo: generate_memo_for(row),
@@ -60,9 +70,7 @@ module MFYNAB
60
70
  memo_parts
61
71
  .delete_if { _1.nil? || _1.empty? }
62
72
  .join(" - ")
63
- .slice(0, 200) # YNAB's API currently limits memo to 200 characters,
64
- # even though YNAB itself allows longer memos. See:
65
- # https://github.com/ynab/ynab-sdk-ruby/issues/77
73
+ .slice(0, MEMO_MAX_LENGTH)
66
74
  end
67
75
 
68
76
  # ⚠️ Be very careful when changing this method!
@@ -86,16 +94,14 @@ module MFYNAB
86
94
  # but changing it now would require a lot of work in preventing import
87
95
  # duplicates due to inconsistent import_id.
88
96
  prefix = "MFBY:v1:"
89
-
90
- max_length = 36 # YNAB API limit
91
- id_max_length = 28 # this leaves 8 characters for the prefix
97
+ digest_max_length = IMPORT_ID_MAX_LENGTH - prefix.length
92
98
 
93
99
  id = row["id"]
94
100
 
95
101
  # Only hash if the ID would exceed YNAB's limit.
96
102
  # This improves backwards compatibility with old import_ids.
97
- if prefix.length + id.length > max_length
98
- 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]
99
105
  end
100
106
 
101
107
  prefix + id
@@ -113,12 +119,17 @@ module MFYNAB
113
119
 
114
120
  ynab_account_name = matching_mapping["ynab_name"]
115
121
 
116
- ynab_account = accounts.find do |account|
122
+ ynab_accounts = accounts.select do |account|
117
123
  account.name.include?(ynab_account_name)
118
124
  end
119
- raise "No YNAB account found with name #{ynab_account_name}." unless ynab_account
120
125
 
121
- 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
122
133
  end
123
134
 
124
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.3
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-07-26 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
@@ -86,6 +102,7 @@ licenses:
86
102
  metadata:
87
103
  homepage_uri: https://github.com/davidstosik/moneyforward_ynab
88
104
  source_code_uri: https://github.com/davidstosik/moneyforward_ynab
105
+ changelog_uri: https://github.com/davidstosik/moneyforward_ynab/blob/main/CHANGELOG.md
89
106
  rubygems_mfa_required: 'true'
90
107
  post_install_message:
91
108
  rdoc_options: []
@@ -102,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
119
  - !ruby/object:Gem::Version
103
120
  version: '0'
104
121
  requirements: []
105
- rubygems_version: 3.5.11
122
+ rubygems_version: 3.5.22
106
123
  signing_key:
107
124
  specification_version: 4
108
125
  summary: Sync transaction history from MoneyForward to YNAB.