mfynab 0.1.4 → 0.3.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: dfe614bd0fee4c3bd2c60de5b1fb18105f0aaa58b889ffd12fcdbd63c1144bcb
4
+ data.tar.gz: 07420fe5903a4ab62d99ac5feb644d731976ea0af21b9ea8e0e60e6c36ee5b45
5
5
  SHA512:
6
- metadata.gz: 0bb90df5c776bf130ec6efcb26082b8bb9dfa94463fd96e92da4c319392060cfd9f20d555e6b5598025f6952893044e331030e86a8d4817afd8186c87775e301
7
- data.tar.gz: 17500b0d34590b0fa4b6689d5fc9e30b56620c0a7fb4cd036c7cf995e6db90fd2b1bc0319d66e6ba1b12d07e97bd07af3d7160557eda047c9cade107d2841c66
6
+ metadata.gz: 1b2085738c14c7615632c38d3423c6fdf55c2c21a91c174e01b0fa04e32674721b5f87bbe65a1a50661b34c1dd36b223994a98b6a580691579da10b38a82193b
7
+ data.tar.gz: 90afb5a30ecdff9033fe954c79f58e6a9776a56458a4f08ffbb340b4621e0a4235ce729a65ebe0b0e08a5628a3a84fa26c95c215bb36e2adc73622447b676f13
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
@@ -1,92 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "mfynab/config"
4
5
  require "mfynab/money_forward"
6
+ require "mfynab/money_forward/session"
5
7
  require "mfynab/money_forward_data"
6
8
  require "mfynab/ynab_transaction_importer"
7
9
 
8
- class CLI
9
- def self.start(argv)
10
- new(argv).start
11
- end
12
-
13
- def initialize(argv)
14
- @argv = argv
15
- end
16
-
17
- def start
18
- logger.info("Running...")
10
+ module MFYNAB
11
+ class CLI
12
+ def self.start(argv)
13
+ new(argv).start
14
+ end
19
15
 
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
- )
16
+ def initialize(argv)
17
+ @argv = argv
18
+ end
26
19
 
27
- data = MFYNAB::MoneyForwardData.new(logger: logger)
28
- data.read_all_csv(save_path)
20
+ def start
21
+ logger.info("Running...")
22
+ money_forward.update_accounts(money_forward_account_names)
23
+ data = money_forward.fetch_data(config.months_to_sync)
29
24
  ynab_transaction_importer.run(data.to_h)
25
+ logger.info("Done!")
30
26
  end
31
27
 
32
- logger.info("Done!")
33
- end
34
-
35
- private
28
+ private
36
29
 
37
- attr_reader :argv
30
+ attr_reader :argv
38
31
 
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
32
+ def money_forward_account_names
33
+ @_money_forward_account_names = config.accounts.map { _1["money_forward_name"] }
34
+ end
45
35
 
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
36
+ def ynab_transaction_importer
37
+ @_ynab_transaction_importer ||= YnabTransactionImporter.new(
38
+ config.credentials["ynab_access_token"],
39
+ config.ynab_budget,
40
+ config.accounts,
41
+ logger: logger,
42
+ )
43
+ end
54
44
 
55
- def months_to_sync
56
- config.fetch("months_to_sync", 3)
57
- end
45
+ def money_forward
46
+ @_money_forward ||= MoneyForward.new(session, logger: logger)
47
+ end
58
48
 
59
- def money_forward
60
- @_money_forward ||= MFYNAB::MoneyForward.new(logger: logger)
61
- end
49
+ def session
50
+ @_session ||= MoneyForward::Session.new(
51
+ username: config.credentials["moneyforward_username"],
52
+ password: config.credentials["moneyforward_password"],
53
+ logger: logger,
54
+ )
55
+ end
62
56
 
63
- def config_file
64
- raise "You need to pass a config file" if argv.empty?
57
+ def config_file
58
+ raise "You need to pass a config file" if argv.empty?
65
59
 
66
- argv[0]
67
- end
60
+ argv[0]
61
+ end
68
62
 
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
63
+ def config
64
+ @_config ||= Config.from_yaml(config_file, logger)
65
+ end
80
66
 
81
- def logger
82
- @_logger ||= Logger.new($stdout, level: logger_level)
83
- end
67
+ def logger
68
+ @_logger ||= Logger.new($stdout, level: logger_level)
69
+ end
84
70
 
85
- def logger_level
86
- if ENV.fetch("DEBUG", nil)
87
- Logger::DEBUG
88
- else
89
- Logger::INFO
71
+ def logger_level
72
+ if ENV.fetch("DEBUG", nil)
73
+ Logger::DEBUG
74
+ else
75
+ Logger::INFO
76
+ end
90
77
  end
91
- end
78
+ end
92
79
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mfynab/credentials_normalizer"
4
+
5
+ module MFYNAB
6
+ class Config
7
+ DEFAULT_MONTHS_TO_SYNC = 3
8
+
9
+ def initialize(hash_config, logger)
10
+ @hash_config = hash_config
11
+ @logger = logger
12
+ end
13
+
14
+ def self.from_yaml(file, logger)
15
+ yaml_data = YAML.load_file(file)
16
+ # Backwards compatibility: support old config files that still have a top-level key.
17
+ # TODO: at some point (major version bump), we should probably remove this.
18
+ unless yaml_data.key?("accounts")
19
+ logger.warn("Top-level key in configuration file is deprecated. Please remove it.")
20
+ yaml_data = yaml_data.values.first
21
+ end
22
+
23
+ raise "Invalid configuration file" unless yaml_data.key?("accounts")
24
+
25
+ new(yaml_data, logger)
26
+ end
27
+
28
+ def ynab_budget
29
+ hash_config.fetch("ynab_budget")
30
+ end
31
+
32
+ def months_to_sync
33
+ hash_config.fetch("months_to_sync", DEFAULT_MONTHS_TO_SYNC)
34
+ end
35
+
36
+ def accounts
37
+ hash_config.fetch("accounts", [])
38
+ end
39
+
40
+ def credentials
41
+ @_credentials ||= CredentialsNormalizer.new(hash_config, logger).normalize
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :hash_config, :logger
47
+ end
48
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MFYNAB
4
+ class CredentialsNormalizer
5
+ def initialize(hash_config, logger)
6
+ @hash_config = hash_config
7
+ @logger = logger
8
+ end
9
+
10
+ def normalize
11
+ config = hash_config["credentials"]
12
+ if config.nil?
13
+ logger.warn("No credentials found in configuration file. Please update your configuration file.")
14
+ # This provides backwards compatibility with old configuration files
15
+ # that did not include the new credentials.
16
+ # TODO: at some point (major version bump), we should probably remove this.
17
+ return {
18
+ "ynab_access_token" => ENV.fetch("YNAB_ACCESS_TOKEN"),
19
+ "moneyforward_username" => ENV.fetch("MONEYFORWARD_USERNAME"),
20
+ "moneyforward_password" => ENV.fetch("MONEYFORWARD_PASSWORD"),
21
+ }
22
+ end
23
+
24
+ config.transform_values { normalize_credential(_1) }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :hash_config, :logger
30
+
31
+ def normalize_credential(value)
32
+ case value
33
+ when String
34
+ value
35
+ when Hash
36
+ if !value.key?("type") || value["type"] == "literal"
37
+ value.fetch("value")
38
+ elsif value["type"] == "env"
39
+ ENV.fetch(value.fetch("value"))
40
+ else
41
+ raise "Unknown credential type: #{value['type']}"
42
+ end
43
+ else
44
+ raise "Unknown credential type: #{value.class}"
45
+ end
46
+ end
47
+ end
48
+ 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,109 @@
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
31
- end
32
-
33
- def download_csv(session_id:, path:, months:)
34
- month = Date.today
35
- month -= month.day - 1 # First day of month
22
+ return if finished
36
23
 
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}")
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)
33
+ end
44
34
 
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
35
+ def fetch_data(months)
36
+ Dir.mktmpdir("mfynab") do |save_path|
37
+ download_csv(
38
+ path: save_path,
39
+ months: months,
40
+ )
50
41
 
51
- month = month.prev_month
42
+ MoneyForwardData.new(logger: logger).tap do |data|
43
+ data.read_all_csv(save_path)
52
44
  end
53
45
  end
54
46
  end
55
47
 
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
48
+ def download_csv(path:, months:)
49
+ month = Date.today
50
+ month -= month.day - 1 # First day of month
51
+ months.times do
52
+ date_string = month.strftime("%Y-%m")
59
53
 
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
- )
54
+ logger.info("Downloading CSV for #{date_string}")
67
55
 
68
- result = http.request(request)
69
- raise unless result.is_a?(Net::HTTPSuccess)
70
- raise unless result.body.valid_encoding?
56
+ # FIXME: I don't really need to save the CSV files to disk anymore.
57
+ # Maybe just return parsed CSV data?
58
+ File.open(File.join(path, "#{date_string}.csv"), "wb") do |file|
59
+ file << download_csv_string(date: month)
60
+ end
71
61
 
72
- result.body.encode(Encoding::UTF_8)
62
+ month = month.prev_month
73
63
  end
74
64
  end
75
65
 
66
+ # FIXME: make private or inline
67
+ def download_csv_string(date:)
68
+ # FIXME: handle errors/edge cases
69
+ session
70
+ .http_get(CSV_PATH, from: date.strftime("%Y/%m/%d"))
71
+ .force_encoding(Encoding::SJIS)
72
+ .encode(Encoding::UTF_8)
73
+ end
74
+
76
75
  private
77
76
 
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
77
+ attr_reader :session, :logger
78
+
79
+ def ensure_account_updated(account_status, update_invalid:)
80
+ if account_status.should_update?(update_invalid: update_invalid)
81
+ suffix = "Will update"
82
+ account_status.trigger_update(session)
83
+ updated = false
84
+ elsif account_status.key == :processing
85
+ suffix = "Updating"
86
+ updated = false
87
+ elsif account_status.key == :success
88
+ suffix = "Up to date"
89
+ updated = true
90
+ else
91
+ # FIXME: we can probably handle :additional_request and/or :login right here in the script,
92
+ # if we can find the time.
93
+ # FIXME: sometimes, nothing can be done about the status, for example, I can currently see モバイルSUICA
94
+ # show an `errors` status with this message:
95
+ # ただいま大変混み合っております。今しばらくお待ちいただきますようお願いいたします。 失敗日時:02/22 14:03
96
+ # In other words: "please wait". Maybe improve messaging?
97
+ suffix = "Ignoring"
98
+ updated = true
99
+ logger.warn(account_status.invalid_state_warning)
100
+ end
101
+
102
+ # FIXME: I'll probably want to refresh the display instead of relogging everything every 5 seconds.
103
+ # FIXME: would be nice to tabulate the info better, for readability.
104
+ logger.info("#{account_status.name}:\t#{account_status.key} (#{account_status.updated_at}) - #{suffix}")
105
+
106
+ updated
93
107
  end
94
108
  end
95
109
  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.3.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.3.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-26 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
@@ -76,7 +90,11 @@ extra_rdoc_files: []
76
90
  files:
77
91
  - exe/mfynab
78
92
  - lib/mfynab/cli.rb
93
+ - lib/mfynab/config.rb
94
+ - lib/mfynab/credentials_normalizer.rb
79
95
  - lib/mfynab/money_forward.rb
96
+ - lib/mfynab/money_forward/account_status.rb
97
+ - lib/mfynab/money_forward/session.rb
80
98
  - lib/mfynab/money_forward_data.rb
81
99
  - lib/mfynab/version.rb
82
100
  - lib/mfynab/ynab_transaction_importer.rb
@@ -103,7 +121,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
121
  - !ruby/object:Gem::Version
104
122
  version: '0'
105
123
  requirements: []
106
- rubygems_version: 3.5.11
124
+ rubygems_version: 3.5.22
107
125
  signing_key:
108
126
  specification_version: 4
109
127
  summary: Sync transaction history from MoneyForward to YNAB.