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 +4 -4
- data/exe/mfynab +1 -1
- data/lib/mfynab/cli.rb +55 -68
- data/lib/mfynab/config.rb +48 -0
- data/lib/mfynab/credentials_normalizer.rb +48 -0
- data/lib/mfynab/money_forward/account_status.rb +116 -0
- data/lib/mfynab/money_forward/session.rb +108 -0
- data/lib/mfynab/money_forward.rb +79 -65
- data/lib/mfynab/version.rb +1 -1
- data/lib/mfynab/ynab_transaction_importer.rb +18 -12
- metadata +23 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dfe614bd0fee4c3bd2c60de5b1fb18105f0aaa58b889ffd12fcdbd63c1144bcb
|
4
|
+
data.tar.gz: 07420fe5903a4ab62d99ac5feb644d731976ea0af21b9ea8e0e60e6c36ee5b45
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1b2085738c14c7615632c38d3423c6fdf55c2c21a91c174e01b0fa04e32674721b5f87bbe65a1a50661b34c1dd36b223994a98b6a580691579da10b38a82193b
|
7
|
+
data.tar.gz: 90afb5a30ecdff9033fe954c79f58e6a9776a56458a4f08ffbb340b4621e0a4235ce729a65ebe0b0e08a5628a3a84fa26c95c215bb36e2adc73622447b676f13
|
data/exe/mfynab
CHANGED
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
path: save_path,
|
24
|
-
months: months_to_sync,
|
25
|
-
)
|
16
|
+
def initialize(argv)
|
17
|
+
@argv = argv
|
18
|
+
end
|
26
19
|
|
27
|
-
|
28
|
-
|
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
|
-
|
33
|
-
end
|
34
|
-
|
35
|
-
private
|
28
|
+
private
|
36
29
|
|
37
|
-
|
30
|
+
attr_reader :argv
|
38
31
|
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
45
|
+
def money_forward
|
46
|
+
@_money_forward ||= MoneyForward.new(session, logger: logger)
|
47
|
+
end
|
58
48
|
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
64
|
-
|
57
|
+
def config_file
|
58
|
+
raise "You need to pass a config file" if argv.empty?
|
65
59
|
|
66
|
-
|
67
|
-
|
60
|
+
argv[0]
|
61
|
+
end
|
68
62
|
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
67
|
+
def logger
|
68
|
+
@_logger ||= Logger.new($stdout, level: logger_level)
|
69
|
+
end
|
84
70
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
71
|
+
def logger_level
|
72
|
+
if ENV.fetch("DEBUG", nil)
|
73
|
+
Logger::DEBUG
|
74
|
+
else
|
75
|
+
Logger::INFO
|
76
|
+
end
|
90
77
|
end
|
91
|
-
|
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
|
data/lib/mfynab/money_forward.rb
CHANGED
@@ -1,95 +1,109 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
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
|
15
|
-
@
|
10
|
+
def initialize(session, logger:)
|
11
|
+
@session = session
|
16
12
|
@logger = logger
|
17
13
|
end
|
18
14
|
|
19
|
-
def
|
20
|
-
|
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
|
-
|
27
|
-
|
18
|
+
finished = account_statuses.map do |account_status|
|
19
|
+
ensure_account_updated(account_status, update_invalid: update_invalid)
|
20
|
+
end.all?
|
28
21
|
|
29
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
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
|
57
|
-
|
58
|
-
|
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
|
-
|
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
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
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 :
|
79
|
-
|
80
|
-
def
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
data/lib/mfynab/version.rb
CHANGED
@@ -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,
|
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,
|
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 >
|
103
|
-
id = Digest::SHA256.hexdigest(id)[0,
|
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
|
-
|
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
|
-
|
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.
|
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:
|
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.
|
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.
|
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.
|
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.
|