feidee_utils 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 874081168fb97f00d101387c1d82212bf21d2e4a
4
+ data.tar.gz: 53f93aa6cc70cdd9f788c2da1cbabaa02b8b7116
5
+ SHA512:
6
+ metadata.gz: 502cd9d3a73072c9dc7603774922935e03e4b2b79b1a79f8cec9378760ddfbe5a6f9c75367dc6e356a942f6a795dc7fe2a81c309b338135ac9941a478d099f82
7
+ data.tar.gz: df7c635377ae61ad429f1a895798ea1bfa00acc054699593598f01643f4f3860267b2a61151796e2cd946dca3f815bb8e551ef3e92943b21ca99b6b2329dfda1
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Liqing Muyi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,9 @@
1
+ Feidee Utils
2
+ ============
3
+
4
+ Free user data from [Feidee](http://www.feidee.com) private backups (.kbf).
5
+
6
+ More details
7
+ -----------
8
+
9
+ TODO
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require 'rake/testtask'
2
+
3
+ Rake::TestTask.new do |t|
4
+ t.libs << 'test'
5
+ t.pattern = 'test/**/*_test.rb'
6
+ end
7
+
8
+ desc "Run tests"
9
+ task :default => :test
@@ -0,0 +1,132 @@
1
+ require 'feidee_utils/record'
2
+ require 'feidee_utils/account_group'
3
+ require 'bigdecimal'
4
+
5
+ module FeideeUtils
6
+ class Account < Record
7
+ def validate_integrity
8
+ raise "Account type should always be 0, but it's #{field["type"]}.\n" + inspect unless field["type"] == 0
9
+ raise "Account usedCount should always be 0, but it's #{field["usedCount"]}.\n" + inspect unless field["usedCount"] == 0
10
+ raise "Account uuid should always be empty, but it's #{field["uuid"]}.\n" + inspect unless field["uuid"].to_s.empty?
11
+ raise "Account hierachy contains more than 2 levels.\n" + inspect unless flat_parent_hierachy?
12
+ raise "Account hidden should be either 0 or 1, but it's #{raw_hidden}.\n" + inspect unless (raw_hidden == 1 or raw_hidden == 0)
13
+ end
14
+
15
+ def self.validate_integrity_globally
16
+ if self.find_by_id(-1) != nil
17
+ raise "-1 is used as the parent POID placeholder of a parent account. " +
18
+ "Account of POID -1 should not exist."
19
+ end
20
+ end
21
+
22
+ FieldMappings = {
23
+ name: "name",
24
+ raw_balance: "balance",
25
+ raw_credit: "amountOfCredit",
26
+ raw_debit: "amountOfLiability",
27
+ currency: "currencyType",
28
+ parent_poid: "parent",
29
+ memo: "memo",
30
+ ordered: "ordered",
31
+ # Examples: saving accounts, credit cards, cash, insurances and so on.
32
+ account_group_poid: "accountGroupPOID",
33
+ raw_hidden: "hidden",
34
+ }.freeze
35
+
36
+ IgnoredFields = [
37
+ "tradingEntityPOID",
38
+ "type", # Always 0
39
+ "usedCount", # Always 0
40
+ "uuid", # Always empty.
41
+ "code", # WTF
42
+ "clientID", # WTF
43
+ ].freeze
44
+
45
+ define_accessors(FieldMappings)
46
+
47
+ # NOTE: balance is not set for credit cards etc. Instead
48
+ # credit/debit are used.
49
+ # Guess: The special behavior is be controlled by
50
+ # account_group_poid. Again, the code can work in all cases,
51
+ # thus no check is done.
52
+ def balance
53
+ to_bigdecimal(raw_balance) + credit - debit
54
+ end
55
+
56
+ def credit
57
+ to_bigdecimal(raw_credit)
58
+ end
59
+
60
+ def debit
61
+ to_bigdecimal(raw_debit)
62
+ end
63
+
64
+ def hidden?
65
+ raw_hidden == 1
66
+ end
67
+
68
+ def account_group
69
+ self.class.environment::AccountGroup.find_by_id(account_group_poid)
70
+ end
71
+
72
+ # Parent related.
73
+ def parent
74
+ self.class.find_by_id(parent_poid)
75
+ end
76
+
77
+ def has_parent?
78
+ parent_poid != 0 && !flagged_as_parent?
79
+ end
80
+
81
+ def flagged_as_parent?
82
+ # Account with POID -1 doesn't exist. It's just a special
83
+ # POID used to indicate that this account itself is the parent
84
+ # of some other accounts.
85
+ parent_poid == -1
86
+ end
87
+
88
+ def flat_parent_hierachy?
89
+ !has_parent? or parent.flagged_as_parent?
90
+ end
91
+
92
+ def children
93
+ arr = []
94
+ self.class.database.query("SELECT * FROM #{self.class.table_name} WHERE parent = ?", poid) do |result|
95
+ result.each do |raw_row|
96
+ arr << self.class.new(result.columns, result.types, raw_row)
97
+ end
98
+ end
99
+ arr
100
+ end
101
+
102
+ class ModifiedAccount < Record::ModifiedRecord
103
+ define_custom_methods([
104
+ :balance,
105
+ :credit,
106
+ :debit,
107
+ :parent,
108
+ ])
109
+ define_default_methods(FieldMappings)
110
+ end
111
+
112
+ # Schema:
113
+ # accountPOID LONG NOT NULL,
114
+ # name varchar(100) NOT NULL,
115
+ # tradingEntityPOID integer NOT NULL,
116
+ # lastUpdateTime] LONG,
117
+ # usedCount integer DEFAULT 0,
118
+ # accountGroupPOID integer,
119
+ # balance decimal(12, 2),
120
+ # currencyType varchar(50) default 'CNY',
121
+ # memo varchar(200),
122
+ # type integer DEFAULT 0,
123
+ # amountOfLiability decimal(12, 2)DEFAULT 0,
124
+ # amountOfCredit decimal(12, 2)DEFAULT 0,
125
+ # ordered integer default 0,
126
+ # code VARCHAR(20),
127
+ # parent LONG default 0,
128
+ # hidden integer default 0,
129
+ # clientID LONG default 0,
130
+ # uuid varchar(200),
131
+ end
132
+ end
@@ -0,0 +1,54 @@
1
+ require 'feidee_utils/record'
2
+ require 'feidee_utils/mixins/parent_and_path'
3
+ require 'feidee_utils/mixins/type'
4
+
5
+ module FeideeUtils
6
+ class AccountGroup < Record
7
+ include FeideeUtils::Mixins::ParentAndPath
8
+ include FeideeUtils::Mixins::Type
9
+
10
+ def validate_integrity
11
+ validate_depth_integrity
12
+ validate_one_level_path_integrity
13
+
14
+ unless (poid == 1 and raw_type == -1) or (raw_type >= 0 and raw_type <= 2)
15
+ raise "Unexpected account group type #{raw_type}.\n" + inspect
16
+ end
17
+ end
18
+
19
+ FieldMappings = {
20
+ name: "name",
21
+ parent_poid: "parentAccountGroupPOID",
22
+ raw_path: "path",
23
+ depth: "depth",
24
+ raw_type: "type",
25
+ ordered: "ordered",
26
+ }.freeze
27
+
28
+ IgnoredFields = [
29
+ "userTradingEntityPOID", # WTF
30
+ "_tempIconName", # Icon name in the app
31
+ "clientID", # WTF
32
+ ].freeze
33
+
34
+ define_accessors(FieldMappings)
35
+
36
+ define_type_enum({
37
+ 0 => :asset,
38
+ 1 => :liability,
39
+ 2 => :claim,
40
+ })
41
+
42
+ # Schema
43
+ # accountGroupPOID long not null
44
+ # name varchar(100) not null
45
+ # parentAccountGroupPOID long not null
46
+ # path varchar(200)
47
+ # depth integer
48
+ # lastUpdateTime long
49
+ # userTradingEntityPOID long
50
+ # _tempIconName varchar(100) default defaultAccountGroupIcon
51
+ # type integer default 1
52
+ # ordered integer default 0
53
+ end
54
+ end
@@ -0,0 +1,76 @@
1
+ require 'feidee_utils/record'
2
+ require 'feidee_utils/mixins/parent_and_path'
3
+ require 'feidee_utils/mixins/type'
4
+
5
+ module FeideeUtils
6
+ class Category < Record
7
+ include FeideeUtils::Mixins::ParentAndPath
8
+ include FeideeUtils::Mixins::Type
9
+
10
+ def validate_integrity
11
+ validate_depth_integrity
12
+ validate_one_level_path_integrity
13
+ raise "Category usedCount should always be 0, but it's #{field["usedCount"]}.\n" + inspect unless field["usedCount"] == 0
14
+ end
15
+
16
+ def self.validate_integrity_globally
17
+ project_root_code = 2
18
+ if TypeEnum[project_root_code] != :project_root
19
+ raise "The type code of project root has been changed, please update the code."
20
+ end
21
+
22
+ rows = self.database.execute <<-SQL
23
+ SELECT #{id_field_name}, #{FieldMappings[:name]} FROM #{table_name}
24
+ WHERE #{FieldMappings[:raw_type]}=#{project_root_code};
25
+ SQL
26
+
27
+ if rows.length > 1
28
+ poids = rows.map do |row| row[0] end
29
+ raise "More than one category have type project_root. IDs are #{poids.inspect}."
30
+ elsif rows.length == 1
31
+ category_name = rows[0][1]
32
+ if category_name != "projectRoot" and category_name != "root"
33
+ raise "Category #{category_name} has type project_root. ID: #{rows[0][0]}."
34
+ end
35
+ end
36
+ end
37
+
38
+ FieldMappings = {
39
+ name: "name",
40
+ parent_poid: "parentCategoryPOID",
41
+ raw_path: "path",
42
+ depth: "depth",
43
+ raw_type: "type",
44
+ ordered: "ordered",
45
+ }.freeze
46
+
47
+ IgnoredFields = [
48
+ "userTradingEntityPOID", # WTF
49
+ "_tempIconName", # Icon name in the app
50
+ "usedCount", # Always 0.
51
+ "clientID", # WTF
52
+ ].freeze
53
+
54
+ define_accessors(FieldMappings)
55
+
56
+ define_type_enum({
57
+ 0 => :expenditure,
58
+ 1 => :income,
59
+ 2 => :project_root, # unkown
60
+ })
61
+
62
+ # Schema
63
+ # categoryPOID LONG NOT NULL
64
+ # name varchar(100) NOT NULL
65
+ # parentCategoryPOID LONG NOT NULL
66
+ # path VARCHAR(200)
67
+ # depth INTEGER
68
+ # lastUpdateTime LONG
69
+ # userTradingEntityPOID LONG
70
+ # _tempIconName VARCHAR(100) DEFAULT defaultIcon,
71
+ # usedCount integer default 0,
72
+ # type integer default 0,
73
+ # ordered integer default 0,
74
+ # clientID LONG default 0,
75
+ end
76
+ end
@@ -0,0 +1,190 @@
1
+ require 'sqlite3'
2
+ require 'feidee_utils/record'
3
+
4
+ # A thin wrapper around SQLite3
5
+ module FeideeUtils
6
+ class Database < SQLite3::Database
7
+ Tables = {
8
+ accounts: "t_account",
9
+ account_groups: "t_account_group",
10
+ categories: "t_category",
11
+ transactions: "t_transaction",
12
+
13
+ metadata: "t_metadata",
14
+ profile: "t_profile",
15
+ }.freeze
16
+
17
+ PotentialUsefulTables = %w(
18
+ t_account_book
19
+ t_account_info
20
+ t_budget_item
21
+ t_fund_holding
22
+ t_fund_trans
23
+ t_module_stock_holding
24
+ t_module_stock_tran
25
+ ).freeze
26
+
27
+ attr_reader :sqlite_file
28
+ attr_reader :platform, :sqlite_name, :sqlite_timestamp
29
+ attr_reader :missing_tables
30
+ attr_reader :namespaced
31
+
32
+ def initialize(private_sqlite, strip = false)
33
+ @sqlite_file = Database.feidee_to_sqlite(private_sqlite)
34
+
35
+ super(@sqlite_file.path)
36
+
37
+ extract_metadata
38
+ drop_unused_tables if strip
39
+
40
+ @namespaced = Record.generate_namespaced_record_classes(self)
41
+ end
42
+
43
+ def sqlite_backup(dest_file_path)
44
+ self.execute("vacuum;")
45
+
46
+ backup_sqlite_db = SQLite3::Database.new(dest_file_path.to_s)
47
+ backup_obj = SQLite3::Backup.new(backup_sqlite_db, "main", self, "main")
48
+ backup_obj.step(-1)
49
+ backup_obj.finish
50
+ backup_sqlite_db.close
51
+ end
52
+
53
+ def validate_integrity_globally
54
+ @namespaced.constants.each do |const|
55
+ @namespaced.const_get(const).validate_integrity_globally if const != :Database
56
+ end
57
+ end
58
+
59
+ private
60
+ def all_tables
61
+ rows = self.execute <<-SQL
62
+ SELECT name FROM sqlite_master
63
+ WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'
64
+ UNION ALL
65
+ SELECT name FROM sqlite_temp_master
66
+ WHERE type IN ('table','view')
67
+ ORDER BY 1
68
+ SQL
69
+ rows.map do |row| row[0] end.sort
70
+ end
71
+
72
+ def drop_unused_tables
73
+ useful_tables = Tables.values + PotentialUsefulTables
74
+ useful_tables = (useful_tables + useful_tables.map do |x| self.class.trash_table_name(x) end).sort
75
+
76
+ # TODO: Record all tables droped.
77
+ (all_tables - useful_tables).each do |table|
78
+ self.execute("DROP TABLE IF EXISTS #{table}");
79
+ end
80
+
81
+ @missing_tables = Tables.values - all_tables
82
+ if !@missing_tables.empty?
83
+ raise "Missing tables: #{@missing_tables} from kbf backup."
84
+ end
85
+ end
86
+
87
+ def extract_metadata
88
+ @platform = self.execute("SELECT platform from #{Tables[:metadata]}")[0][0];
89
+
90
+ @sqlite_name = self.get_first_row("SELECT accountBookName FROM #{Tables[:profile]};")[0];
91
+
92
+ # This is not recorded in the database, so the lastest lastUpdateTime of all
93
+ # transactions is chosen.
94
+ timestamp = self.get_first_row("SELECT max(lastUpdateTime) FROM #{Tables[:transactions]};")[0]
95
+ @sqlite_timestamp = timestamp == nil ? Time.at(0) : Time.at(timestamp / 1000)
96
+ end
97
+
98
+ class << self
99
+ def open_file(file_name)
100
+ Database.new(File.open(file_name))
101
+ end
102
+
103
+ Header = "SQLite format 3\0".force_encoding("binary")
104
+ FeideeHeader_iOS = "%$^#&!@_@- -!F\xff\0".force_encoding('binary')
105
+ FeideeHeader_Android = ("\0" * 13 + "F\xff\0").force_encoding("binary")
106
+
107
+ def feidee_to_sqlite(private_sqlite, sqlite_file = nil)
108
+ # Discard the first a few bytes content.
109
+ private_header = private_sqlite.read(Header.length)
110
+
111
+ unless [FeideeHeader_iOS, FeideeHeader_Android, Header].include? private_header
112
+ raise "Unexpected header #{private_header.inspect} in private sqlite file."
113
+ end
114
+
115
+ # Write the rest to a tempfile.
116
+ sqlite_file ||= Tempfile.new("kingdee_sqlite", binmode: true)
117
+ sqlite_file.binmode
118
+ sqlite_file.write(Header)
119
+ sqlite_file.write(private_sqlite.read)
120
+ sqlite_file.fsync
121
+ sqlite_file
122
+ end
123
+ end
124
+
125
+ class << self
126
+ NoDeleteSuffixTables = %w(account category tag tradingEntity transaction transaction_template)
127
+
128
+ def trash_table_name name
129
+ NoDeleteSuffixTables.each do |core_name|
130
+ if name == "t_" + core_name then
131
+ return "t_" + "deleted_" + core_name;
132
+ end
133
+ end
134
+
135
+ name + "_delete"
136
+ end
137
+ end
138
+
139
+ AllKnownTables = {
140
+ t_account: "As named",
141
+ t_account_book: "A group of accounts, travel accounts etc.",
142
+ t_account_extra: "Extra Feidee account configs, key/value pair.",
143
+ t_account_group: "A group of accounts, saving/chekcing etc.",
144
+ t_account_info: "Additional info of accounts: banks etc.",
145
+ t_accountgrant: "Feidee account VIP related stuff.",
146
+ t_budget_item: "Used to create budgets. An extension of category.",
147
+ t_binding: "Netbank / credit card / Taobao bindings.",
148
+ t_category: "Transaction categories.",
149
+ t_currency: "Currency types.",
150
+ t_exchange: "Currency exchange rates.",
151
+ t_fund: "List of money manage institute names. Abandoned.",
152
+ t_fund_holding: "Fund accounts.",
153
+ t_fund_trans: "Fund transactions.",
154
+ t_fund_price_history: "Fund price history",
155
+ t_id_seed: "ID seeds for all tables.",
156
+ t_import_history: "As named.",
157
+ t_import_source: "Import data from text messages etc.",
158
+
159
+ # JCT stands for Jia Cai Tong, a software for family book keeping.
160
+ # See http://www.feidee.com/jct/
161
+ # JCT is quite obsolete. All the related tables can be safely ignored.
162
+ t_jct_clientdeviceregist: "Client devices.",
163
+ t_jct_clientdevicestatus: "Client devices status.",
164
+ t_jct_syncbookfilelist: "Name of files synced from other devices.",
165
+ t_jct_usergrant: "Maybe if the user has purchased any service.",
166
+ t_jct_userlog: "As named.",
167
+
168
+ t_local_recent: "Local merchandise used recently",
169
+ t_message: "Kingdee ads.",
170
+ t_metadata: "Database version, client version etc.",
171
+ t_module_stock_holding: "Stock accounts.",
172
+ t_module_stock_info: "Stock rates.",
173
+ t_module_stock_trans: "Stock transactions.",
174
+ t_notification: "If and when a notification has been delivered.",
175
+ t_profile: "User profile, default stuff when configuring account books.",
176
+ t_property: "Data collected on user settings, key/value pair.",
177
+ t_syncResource: "???",
178
+ t_sync_logs: "As named.",
179
+ t_tag: "Other support like roles/merchandise.",
180
+ t_tradingEntity: "Merchandise. Used together with t_user in Debt Center.",
181
+ t_trans_debt: "Transactions in Debt Center.",
182
+ t_trans_debt_group: "Transaction groups in Debt Center.",
183
+ t_transaction: "As named.",
184
+ t_transaction_projectcategory_map: "Transaction project.",
185
+ t_transaction_template: "As named. UI.",
186
+ t_user: "Multi-user support.",
187
+ t_usage_count: "As named. Abandoned."
188
+ }
189
+ end
190
+ end
@@ -0,0 +1,36 @@
1
+ require 'zip'
2
+ require 'sqlite3'
3
+ require 'feidee_utils/database'
4
+ require 'feidee_utils/record'
5
+ require 'feidee_utils/transaction'
6
+ require 'feidee_utils/account'
7
+
8
+ module FeideeUtils
9
+ class Kbf
10
+ DatabaseName = 'mymoney.sqlite'
11
+
12
+ attr_reader :zipfile, :sqlite_db
13
+
14
+ def initialize(input_stream)
15
+ @zipfile = Zip::File.open_buffer(input_stream) do |zipfile|
16
+ zipfile.each do |entry|
17
+ if entry.name == DatabaseName
18
+ # Each call to get_input_stream will create a new stream
19
+ @original_sqlite_db_entry = entry
20
+ @sqlite_db = FeideeUtils::Database.new(entry.get_input_stream, true)
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ def extract_original_sqlite(dest_file_path = nil)
27
+ FeideeUtils::Database.feidee_to_sqlite(@original_sqlite_db_entry.get_input_stream, dest_file_path)
28
+ end
29
+
30
+ class << self
31
+ def open_file(file_name)
32
+ return Kbf.new(File.open(file_name))
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,69 @@
1
+ module FeideeUtils
2
+ module Mixins
3
+ # Requires:
4
+ # instance methods: poid, parent_poid, raw_path
5
+ # class methods: find_by_id
6
+ module ParentAndPath
7
+ class InconsistentDepthException < Exception
8
+ end
9
+
10
+ class InconsistentPathException < Exception
11
+ end
12
+
13
+ def validate_depth_integrity
14
+ path_depth = path.length - 1
15
+ if path_depth != depth
16
+ raise InconsistentDepthException,
17
+ "Path is #{path}, but the given depth is #{depth}.\n" +
18
+ inspect
19
+ end
20
+ end
21
+
22
+ def validate_one_level_path_integrity
23
+ path_array = path.clone
24
+ last_poid = path_array.pop
25
+
26
+ if last_poid != poid
27
+ raise InconsistentPathException,
28
+ "The last node in path is #{last_poid}, but the current poid is #{poid}.\n" +
29
+ inspect
30
+ end
31
+
32
+ if has_parent? and path_array != parent.path
33
+ raise InconsistentPathException,
34
+ "Path is #{path}, but path of parent is #{parent.path}.\n" +
35
+ inspect
36
+ end
37
+ end
38
+
39
+ def validate_path_integrity_hard
40
+ cur = self
41
+ step = 0
42
+ while cur or step != path.length
43
+ step += 1
44
+ poid = path[-step]
45
+ if !cur or poid == nil or poid != cur.poid
46
+ raise InconsistentPathException,
47
+ "Reverse path and trace are different at step #{step}. " +
48
+ "Path shows #{poid}, but trace shows #{cur and cur.poid}.\n" +
49
+ inspect
50
+ end
51
+
52
+ cur = cur.has_parent? && cur.parent
53
+ end
54
+ end
55
+
56
+ def path
57
+ @path ||= (raw_path.split("/").map do |poid| poid.to_i end)[1..-1]
58
+ end
59
+
60
+ def parent
61
+ self.class.find_by_id(parent_poid)
62
+ end
63
+
64
+ def has_parent?
65
+ parent_poid != 0
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ module FeideeUtils
2
+ module Mixins
3
+ # Requires:
4
+ # instance methods: raw_type
5
+ module Type
6
+ module ClassMethods
7
+ def define_type_enum type_enum, reverse_lookup = true
8
+ const_set :TypeEnum, type_enum.freeze
9
+
10
+ if reverse_lookup
11
+ enum_values = type_enum.values
12
+ if enum_values.size != enum_values.uniq.size
13
+ raise "Duplicate values in enum #{type_enum}."
14
+ end
15
+
16
+ const_set :TypeCode, type_enum.invert.freeze
17
+ define_singleton_method :type_code do |type_enum_value|
18
+ self::TypeCode[type_enum_value]
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ def self.included klass
25
+ klass.extend ClassMethods
26
+ end
27
+
28
+ def type
29
+ self.class::TypeEnum[raw_type]
30
+ end
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,22 @@
1
+ module FeideeUtils
2
+ class Record
3
+ module Accessors
4
+ def poid
5
+ @field[self.class.id_field_name]
6
+ end
7
+
8
+ def last_update_time
9
+ timestamp_to_time(@field["lastUpdateTime"])
10
+ end
11
+
12
+ module ClassMethods
13
+ def define_accessors field_mappings
14
+ field_mappings.each do |name, key|
15
+ raise "Accessor #{name} already exists in #{self.name}." if method_defined? name
16
+ define_method name do field[key] end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,75 @@
1
+ module FeideeUtils
2
+ class Record
3
+ class ModifiedRecord
4
+ attr_reader :poid
5
+ attr_reader :base, :head
6
+ attr_reader :modified_fields
7
+
8
+ def initialize(poid, base, head)
9
+ raise "Base row doesn't have the given poid." if base.poid != poid
10
+ raise "Head row doesn't have the given poid." if head.poid != poid
11
+ @poid = poid
12
+ @base = base
13
+ @head = head
14
+ @modified_fields = self.class.fields_diff(base.field, head.field)
15
+ end
16
+
17
+ class ValuePair
18
+ attr_reader :old, :new
19
+ def initialize(old, new)
20
+ @old = old
21
+ @new = new
22
+ end
23
+ end
24
+
25
+ def self.fields_diff base, head
26
+ (base.keys.sort | head.keys.sort).inject({}) do |hash, key|
27
+ if base[key] != head[key]
28
+ hash[key] = ValuePair.new(base[key], head[key])
29
+ end
30
+ hash
31
+ end
32
+ end
33
+
34
+ def touched?
35
+ !modified_fields.empty?
36
+ end
37
+
38
+ def changed?
39
+ methods.inject(false) do |acc, name|
40
+ if name.to_s.end_with? "_changed?"
41
+ acc ||= send name
42
+ end
43
+ acc
44
+ end
45
+ end
46
+
47
+ protected
48
+ def self.define_custom_methods fields
49
+ fields.each do |name|
50
+ if !respond_to? name
51
+ define_method name do
52
+ ValuePair.new((base.send name), (head.send name))
53
+ end
54
+ define_method (name.to_s + "_changed?").to_sym do
55
+ (base.send name) != (head.send name)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.define_default_methods field_mappings
62
+ field_mappings.each do |name, key|
63
+ if !respond_to? name
64
+ define_method name do
65
+ modified_fields[key]
66
+ end
67
+ define_method (name.to_s + "_changed?").to_sym do
68
+ modified_fields.has_key? key
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,39 @@
1
+ require 'set'
2
+
3
+ module FeideeUtils
4
+ class Record
5
+ module Namespaced
6
+ module ClassMethods
7
+ attr_reader :child_classes
8
+
9
+ # Must be invoked by Record.inherited
10
+ def collect_subclass(child_class)
11
+ @child_classes ||= Set.new
12
+ @child_classes.add(child_class)
13
+ end
14
+
15
+ # To use Record with different databases, generate a set of classes for each db
16
+ def generate_namespaced_record_classes(db)
17
+ @child_classes ||= Set.new
18
+ this = self
19
+ Module.new do |mod|
20
+ const_set(:Database, Module.new {
21
+ define_method("database") { db }
22
+ define_method("environment") { mod }
23
+ })
24
+
25
+ this.child_classes.each do |child_class|
26
+ if child_class.name.start_with? FeideeUtils.name
27
+ class_name = child_class.name.sub(/#{FeideeUtils.name}::/, '')
28
+ # Generate a const for the child class
29
+ const_set(class_name, Class.new(child_class) {
30
+ extend mod::Database
31
+ })
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+
2
+ module FeideeUtils
3
+ class Record
4
+ module Persistent
5
+ module ClassMethods
6
+ # Names
7
+ # Must be invoked by Record.inherited
8
+ def genereate_names subclass
9
+ entity_name =
10
+ if i = subclass.name.rindex("::")
11
+ subclass.name[(i+2)..-1]
12
+ else
13
+ subclass.name
14
+ end
15
+
16
+ id_field_name = entity_name.sub(/^[A-Z]/) { $&.downcase } + "POID"
17
+ table_name = "t_" + entity_name.gsub(/([a-z\d])([A-Z\d])/, '\1_\2').downcase
18
+ subclass.class_exec do
19
+ define_singleton_method :entity_name do entity_name end
20
+ define_singleton_method :id_field_name do id_field_name end
21
+ define_singleton_method :table_name do table_name end
22
+ end
23
+ end
24
+
25
+ # Persistent
26
+ def all
27
+ arr = []
28
+ database.query("SELECT * FROM #{self.table_name}") do |result|
29
+ result.each do |raw_row|
30
+ arr << self.new(result.columns, result.types, raw_row)
31
+ end
32
+ end
33
+ arr
34
+ end
35
+
36
+ def find_by_id(id)
37
+ raw_result = database.query("SELECT * FROM #{self.table_name} WHERE #{self.id_field_name} = ?", id)
38
+
39
+ raw_row = raw_result.next
40
+ return nil if raw_row == nil
41
+
42
+ if raw_result.next != nil
43
+ raise "Getting more than one result with the same ID #{id} in table #{self.table_name}."
44
+ end
45
+
46
+ self.new(raw_result.columns, raw_result.types, raw_row)
47
+ end
48
+
49
+ def find(id)
50
+ find_by_id(id) or raise "No #{self.name} of poid #{id} found"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,15 @@
1
+ module FeideeUtils
2
+ class Record
3
+ module Utils
4
+ protected
5
+ def timestamp_to_time num
6
+ Time.at(num / 1000.0, num % 1000)
7
+ end
8
+
9
+ def to_bigdecimal number
10
+ # Be aware of the precision lost from String -> Float -> BigDecimal.
11
+ BigDecimal.new(number, 12).round(2)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,66 @@
1
+ require 'feidee_utils/record/accessors'
2
+ require 'feidee_utils/record/namespaced'
3
+ require 'feidee_utils/record/persistent'
4
+ require 'feidee_utils/record/utils'
5
+ require 'feidee_utils/record/modified_record'
6
+
7
+ module FeideeUtils
8
+ # The implementation here is wired.
9
+ # The goal is to create a class hierachy similar to ActiveRecord, where every table is represented by a
10
+ # subclass of ActiveRecord::Base. Class methods, attribute accessors and almost all other functionalities
11
+ # are provided by ActiveRecord::Base. For example, Base.all(), Base.find_by_id() are tied to a specific
12
+ # table in a specific database.
13
+ # The problem we are solving here is not the same as ActiveRecord. In ActiveRecord, the databases
14
+ # are static, i.e. they won't be changed at runtime. Meanwhile, in our case, new databases can be created
15
+ # at runtime, when a new KBF backup file is uploaded. Furthermore, multiple instances of different databases
16
+ # can co-exist at the same time. To provide the same syntax as ActiveRecord, a standalone "Base" class has
17
+ # to be created for each database.
18
+ # In our implementation, when a new database is created, a subclass of Record is created in a new namepsace.
19
+ # For each subclass of Record, a new subclass is copied to the new namespace, with it's database method
20
+ # overloaded.
21
+ class Record
22
+ attr_reader :field, :field_type
23
+
24
+ public
25
+ def initialize(columns, types, raw_row)
26
+ @field = Hash[ columns.zip(raw_row) ]
27
+ @field_type = Hash[ columns.zip(types) ]
28
+
29
+ validate_integrity
30
+ end
31
+
32
+ def validate_integrity
33
+ # Do nothing.
34
+ end
35
+
36
+ def self.validate_integrity_globally
37
+ # Do nothing.
38
+ end
39
+
40
+ class << self
41
+ protected
42
+ def database
43
+ raise NotImplementedError.new("Subclasses must set database")
44
+ end
45
+
46
+ private
47
+ def inherited subclass
48
+ if subclass.name != nil and subclass.name.start_with? FeideeUtils.name
49
+ collect_subclass subclass
50
+ genereate_names subclass
51
+ end
52
+ end
53
+ end
54
+
55
+ # Basic accessors, poid, last update time, etc.
56
+ include Accessors
57
+ # Helper methods to define accessors
58
+ extend Accessors::ClassMethods
59
+ # Helper methods to define new classes in a given namespace.
60
+ extend Namespaced::ClassMethods
61
+ # Helper methods to look up records.
62
+ extend Persistent::ClassMethods
63
+ # Helper methods to convert data types.
64
+ include Utils
65
+ end
66
+ end
@@ -0,0 +1,232 @@
1
+ require 'feidee_utils/record'
2
+ require 'feidee_utils/mixins/type'
3
+
4
+ module FeideeUtils
5
+ class Transaction < Record
6
+ include FeideeUtils::Mixins::Type
7
+
8
+ class TransferLackBuyerOrSellerException < Exception
9
+ end
10
+
11
+ class TransferWithCategoryException < Exception
12
+ end
13
+
14
+ class InconsistentBuyerAndSellerSetException < Exception
15
+ end
16
+
17
+ class InconsistentCategoryException < Exception
18
+ end
19
+
20
+ class InconsistentAmountException < Exception
21
+ end
22
+
23
+ def validate_integrity
24
+ if is_transfer?
25
+ unless buyer_account_poid != 0 and seller_account_poid != 0
26
+ raise TransferLackBuyerOrSellerException,
27
+ "Both buyer and seller should be set in a transfer. " +
28
+ "Buyer account POID: #{buyer_account_poid}. Seller account POID: #{seller_account_poid}.\n" +
29
+ inspect
30
+ end
31
+ unless buyer_category_poid == 0 and seller_category_poid == 0
32
+ raise TransferWithCategoryException,
33
+ "Neither buyer or seller category should be set in a transfer. " +
34
+ "Buyer category POID: #{buyer_category_poid}. Seller category POID: #{seller_category_poid}.\n" +
35
+ inspect
36
+ end
37
+ else
38
+ unless (buyer_account_poid == 0) ^ (seller_account_poid == 0)
39
+ raise InconsistentBuyerAndSellerSetException,
40
+ "Exactly one of buyer and seller should be set in a non-transfer transaction. " +
41
+ "Buyer account POID: #{buyer_account_poid}. Seller account POID: #{seller_account_poid}.\n" +
42
+ inspect
43
+ end
44
+
45
+ # We could enforce that category is set to the matching party (buyer or seller) of account.
46
+ # However the implementation could handle all situations, as long as only one of them is set.
47
+ # Thus no extra check is done here.
48
+ unless buyer_category_poid == 0 or seller_category_poid == 0
49
+ raise InconsistentCategoryException,
50
+ "Only one of buyer and seller category should be set in a non-transfer transaction. "
51
+ "Buyer category POID: #{buyer_category_poid}. Seller category POID: #{seller_category_poid}.\n" +
52
+ inspect
53
+ end
54
+ end
55
+
56
+ unless buyer_deduction == seller_addition
57
+ raise InconsistentAmountException,
58
+ "Buyer and seller should have the same amount set. " +
59
+ "Buyer deduction: #{buyer_deduction}, seller_addition: #{seller_addition}.\n" +
60
+ inspect
61
+ end
62
+ end
63
+
64
+ class TransfersNotPaired < Exception
65
+ end
66
+
67
+ def self.validate_integrity_globally
68
+ uuids_map = all.inject({}) do |uuids, transaction|
69
+ if transaction.is_transfer?
70
+ uuid = transaction.uuid
71
+ uuids[uuid] ||= [nil, nil]
72
+ uuids[uuid][transaction.raw_type - 2] = transaction
73
+ end
74
+ uuids
75
+ end
76
+
77
+ uuids_map.each do |uuid, transfers|
78
+ valid = true
79
+ valid &&= transfers[0] != nil
80
+ valid &&= transfers[1] != nil
81
+ valid &&= transfers[0].buyer_account_poid == transfers[1].buyer_account_poid
82
+ valid &&= transfers[0].seller_account_poid == transfers[1].seller_account_poid
83
+ raise TransfersNotPaired.new([uuid] + transfers) unless valid
84
+ end
85
+ end
86
+
87
+ FieldMappings = {
88
+ raw_created_at: "createdTime",
89
+ raw_modified_at: "modifiedTime",
90
+ raw_trade_at: "tradeTime",
91
+ raw_type: "type",
92
+ memo: "memo",
93
+ buyer_account_poid: "buyerAccountPOID",
94
+ buyer_category_poid: "buyerCategoryPOID",
95
+ seller_account_poid: "sellerAccountPOID",
96
+ seller_category_poid: "sellerCategoryPOID",
97
+ raw_buyer_deduction: "buyerMoney",
98
+ raw_seller_addition: "sellerMoney",
99
+ uuid: "relation",
100
+ }.freeze
101
+
102
+ IgnoredFields = [
103
+ "creatorTradingEntityPOID",
104
+ "modifierTradingEntityPOID",
105
+ "ffrom", # The signature of the App writting this transaction.
106
+ "photoName", # To be added
107
+ "photoNeedUpload", # To be added
108
+ "relationUnitPOID", # WTF
109
+ "clientID", # WTF
110
+ "FSourceKey", # WTF
111
+ ].freeze
112
+
113
+ define_accessors(FieldMappings)
114
+
115
+ define_type_enum({
116
+ 0 => :expenditure,
117
+ 1 => :income,
118
+ 2 => :transfer_buyer,
119
+ 3 => :transfer_seller,
120
+ 8 => :positive_initial_balance,
121
+ 9 => :negative_initial_balance,
122
+ })
123
+
124
+ def created_at
125
+ timestamp_to_time(raw_created_at)
126
+ end
127
+
128
+ def modified_at
129
+ timestamp_to_time(raw_modified_at)
130
+ end
131
+
132
+ def trade_at
133
+ timestamp_to_time(raw_trade_at)
134
+ end
135
+
136
+ def has_category?
137
+ category_poid != 0
138
+ end
139
+
140
+ def category_poid
141
+ # At least one of those two must be 0.
142
+ buyer_category_poid + seller_category_poid
143
+ end
144
+
145
+ # Amount accessors
146
+
147
+ def buyer_deduction
148
+ sign_by_type(raw_buyer_deduction)
149
+ end
150
+
151
+ def seller_addition
152
+ sign_by_type(raw_seller_addition)
153
+ end
154
+
155
+ def amount
156
+ # Buyer deduction is always equal to seller addition.
157
+ (buyer_deduction + seller_addition) / 2
158
+ end
159
+
160
+ def is_transfer?
161
+ type == :transfer_buyer or type == :transfer_seller
162
+ end
163
+
164
+ def is_initial_balance?
165
+ type == :positive_initial_balance or type == :negative_initial_balance
166
+ end
167
+
168
+ def revised_account_poid
169
+ if type == :transfer_buyer
170
+ buyer_account_poid
171
+ elsif type == :transfer_seller
172
+ seller_account_poid
173
+ else
174
+ buyer_account_poid + seller_account_poid
175
+ end
176
+ end
177
+
178
+ def revised_amount
179
+ account_poid = revised_account_poid
180
+ if account_poid == buyer_account_poid
181
+ -buyer_deduction
182
+ elsif account_poid == seller_account_poid
183
+ seller_addition
184
+ else
185
+ raise "Unexpected revised account poid #{account_poid}."
186
+ end
187
+ end
188
+
189
+ class ModifiedTransaction < ModifiedRecord
190
+ define_custom_methods([
191
+ :created_at,
192
+ :modified_at,
193
+ :trade_at,
194
+ :type,
195
+ :category_poid,
196
+ :buyer_deduction,
197
+ :seller_addition,
198
+ :amount,
199
+ ])
200
+ define_default_methods(FieldMappings)
201
+ end
202
+
203
+ private
204
+ def sign_by_type num
205
+ raw_type == 9 ? -num : num
206
+ end
207
+
208
+ # Schema:
209
+ # transactionPOID LONG NOT NULL,
210
+ # createdTime LONG NOT NULL,
211
+ # modifiedTime LONG NOT NULL,
212
+ # tradeTime LONG NOT NULL,
213
+ # memo varchar(100),
214
+ # type integer NOT NULL,
215
+ # creatorTradingEntityPOID LONG,
216
+ # modifierTradingEntityPOID LONG,
217
+ # buyerAccountPOID LONG,
218
+ # buyerCategoryPOID LONG default 0,
219
+ # buyerMoney decimal(12, 2),
220
+ # sellerAccountPOID LONG,
221
+ # sellerCategoryPOID LONG default 0,
222
+ # sellerMoney decimal(12, 2),
223
+ # lastUpdateTime LONG,
224
+ # photoName VARCHAR(100),
225
+ # photoNeedUpload integer default 0,
226
+ # relation varchar(200) default '',
227
+ # relationUnitPOID LONG,
228
+ # ffrom varchar(250) default '',
229
+ # clientID LONG default 0,
230
+ # FSourceKey varchar(100) DEFAULT NULL,
231
+ end
232
+ end
@@ -0,0 +1,9 @@
1
+ require 'feidee_utils/kbf'
2
+ require 'feidee_utils/database'
3
+ require 'feidee_utils/record'
4
+ require 'feidee_utils/account'
5
+ require 'feidee_utils/transaction'
6
+ require 'feidee_utils/category'
7
+
8
+ module FeideeUtils
9
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: feidee_utils
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Liqing Muyi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-05-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rubyzip
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.1.6
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '1.1'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.1.6
33
+ - !ruby/object:Gem::Dependency
34
+ name: sqlite3
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 1.3.10
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '1.3'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 1.3.10
53
+ - !ruby/object:Gem::Dependency
54
+ name: minitest
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '5.8'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 5.8.1
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '5.8'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 5.8.1
73
+ description: Feidee Utils provides a set of ActiveReocrd-like classes to read Feidee
74
+ private backups (.kbf files). It also provides a better abstraction to the general
75
+ format of transaction-account style data.
76
+ email: feideeutils@ditsing.com
77
+ executables: []
78
+ extensions: []
79
+ extra_rdoc_files: []
80
+ files:
81
+ - Gemfile
82
+ - MIT-LICENSE
83
+ - README.md
84
+ - Rakefile
85
+ - lib/feidee_utils.rb
86
+ - lib/feidee_utils/account.rb
87
+ - lib/feidee_utils/account_group.rb
88
+ - lib/feidee_utils/category.rb
89
+ - lib/feidee_utils/database.rb
90
+ - lib/feidee_utils/kbf.rb
91
+ - lib/feidee_utils/mixins/parent_and_path.rb
92
+ - lib/feidee_utils/mixins/type.rb
93
+ - lib/feidee_utils/record.rb
94
+ - lib/feidee_utils/record/accessors.rb
95
+ - lib/feidee_utils/record/modified_record.rb
96
+ - lib/feidee_utils/record/namespaced.rb
97
+ - lib/feidee_utils/record/persistent.rb
98
+ - lib/feidee_utils/record/utils.rb
99
+ - lib/feidee_utils/transaction.rb
100
+ homepage: http://github.com/muyiliqing/feidee_utils
101
+ licenses:
102
+ - MIT
103
+ metadata: {}
104
+ post_install_message:
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 2.0.0
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubyforge_project:
120
+ rubygems_version: 2.4.8
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: Utils to extract useful information from Feidee Mymoney backup.
124
+ test_files: []