feidee_utils 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +21 -0
- data/README.md +9 -0
- data/Rakefile +9 -0
- data/lib/feidee_utils/account.rb +132 -0
- data/lib/feidee_utils/account_group.rb +54 -0
- data/lib/feidee_utils/category.rb +76 -0
- data/lib/feidee_utils/database.rb +190 -0
- data/lib/feidee_utils/kbf.rb +36 -0
- data/lib/feidee_utils/mixins/parent_and_path.rb +69 -0
- data/lib/feidee_utils/mixins/type.rb +34 -0
- data/lib/feidee_utils/record/accessors.rb +22 -0
- data/lib/feidee_utils/record/modified_record.rb +75 -0
- data/lib/feidee_utils/record/namespaced.rb +39 -0
- data/lib/feidee_utils/record/persistent.rb +55 -0
- data/lib/feidee_utils/record/utils.rb +15 -0
- data/lib/feidee_utils/record.rb +66 -0
- data/lib/feidee_utils/transaction.rb +232 -0
- data/lib/feidee_utils.rb +9 -0
- metadata +124 -0
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
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
data/Rakefile
ADDED
@@ -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
|
data/lib/feidee_utils.rb
ADDED
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: []
|