File Coverage

lib/GnuCash/SQLite.pm
Criterion Covered Total %
statement 119 119 100.0
branch 8 8 100.0
condition 2 4 50.0
subroutine 23 23 100.0
pod 2 11 18.1
total 154 165 93.3


line stmt bran cond sub pod time code
1             package GnuCash::SQLite;
2              
3 1     1   659588 use strict;
  1         2  
  1         30  
4 1     1   5 use warnings;
  1         3  
  1         25  
5 1     1   10 use 5.10.0;
  1         3  
6 1     1   647 use UUID::Tiny ':std';
  1         16086  
  1         204  
7 1     1   1741 use DBI;
  1         18731  
  1         64  
8 1     1   8 use DateTime;
  1         3  
  1         23  
9 1     1   5 use Carp;
  1         2  
  1         55  
10 1     1   957 use Path::Tiny;
  1         13809  
  1         1427  
11              
12             =head1 NAME
13              
14             GnuCash::SQLite - A module to access GnuCash SQLite files
15              
16             =head1 VERSION
17              
18             version 0.08
19              
20             =cut
21              
22             our $VERSION = '0.08';
23              
24             sub new {
25 4     4 0 1219 my $class = shift;
26 4         13 my %attr = @_;
27 4         8 my $self = {};
28              
29             croak 'No GnuCash file defined.'
30 4 100       222 unless defined($attr{db});
31             croak "File: $attr{db} does not exist."
32 3 100       12 unless path($attr{db})->is_file;
33              
34 2         212 $self->{db} = $attr{db};
35 2         20 $self->{dbh} = DBI->connect("dbi:SQLite:dbname=$self->{db}","","");
36              
37 2         12841 bless $self, $class;
38 2         7 return $self;
39             }
40              
41             # Create a 32-character UUID
42             sub create_guid {
43 7     7 0 907 my $uuid = create_uuid_as_string(UUID_V1);
44 7         1083 $uuid =~ s/-//g;
45 7         23 return $uuid;
46             }
47              
48             # Given an account name, return the GUID of the currency (aka commodity)
49             # associated with that account
50             sub commodity_guid {
51 3     3 0 678 my $self = shift;
52 3         14 my $account_name = shift;
53              
54 3         8 my $sql = "SELECT commodity_guid FROM accounts "
55             . "WHERE guid = (".$self->account_guid_sql($account_name).")";
56              
57 3         8 return $self->_runsql($sql)->[0][0];
58             }
59              
60             # Given a date in YYYYMMDD format,
61             # This is always in the local timezone
62             # And GnuCash stores all dates in UTC timezone
63             # This function needs to:
64             # 1. Create a date time with the local timezone
65             # 2. Switch to the UTC timezone
66             # 3. Store that timestamp
67             # For example, the 'Asia/Bangkok' timezone is UTC +7:00
68             # given txn date of 20140101 (in the local timezone)
69             # return 20131231170000 (which gets stored in the db)
70             sub UTC_post_date {
71 4     4 0 140 my $self = shift;
72 4         23 my ($YYYY, $MM, $DD) = (shift =~ /(....)(..)(..)/);
73              
74             # Create a new
75 4         25 my $dt = DateTime->new(
76             year => $YYYY,
77             month => $MM,
78             day => $DD,
79             time_zone => 'local' );
80 4         8240 $dt->set_time_zone('UTC');
81 4         51 return $dt->ymd('') . $dt->hms('');
82             }
83              
84             # Returns the system date in YYYYMMDDhhmmss format
85             # Timezone is UTC (GMT 00:00)
86             sub UTC_enter_date {
87 3     3 0 1072 my $dt = DateTime->now();
88 3         787 return $dt->ymd('').$dt->hms('');
89             }
90              
91             # Given an account name, return the GUID of the account
92             sub account_guid {
93 15     15 0 712 my $self = shift;
94 15         26 my $account_name = shift;
95              
96 15         34 my $sql = $self->account_guid_sql($account_name);
97 15         35 return $self->_runsql($sql)->[0][0];
98             }
99              
100             # Given an account name, return the SQL that reads its GUID
101             # Generate a recursive SQL given the full account name e.g. Assets:Cash
102             # A naive implementation may just extract the tail account
103             # i.e. SELECT guid FROM accounts WHERE name = 'Cash';
104             # That fails when accounts of the same name have different parents
105             # e.g. Assets:Husband:Cash and Assets:Wife:Cash
106             sub account_guid_sql {
107 19     19 0 28 my $self = shift;
108 19         38 my ($acct_name) = @_;
109 19         26 my $sub_sql = 'SELECT guid FROM accounts WHERE name = "Root Account"';
110 19         74 foreach my $acct (split ":", $acct_name) {
111 35         113 $sub_sql = 'SELECT guid FROM accounts '
112             . 'WHERE name = "'.$acct.'" '
113             . 'AND parent_guid = ('.$sub_sql.')';
114             }
115 19         50 return $sub_sql;
116             }
117              
118             # Given a guid, return a list of child guids or if none, an empty arrayref
119             sub child_guid {
120 15     15 0 28 my $self = shift;
121 15         24 my $parent_guid = shift;
122              
123 15         46 my $sql = qq/SELECT guid FROM accounts WHERE parent_guid = "$parent_guid"/;
124              
125             # The map belows converts [[x],[y],[z]] into [x,y,z]
126 15         25 my @res = map { $$_[0] } @{ $self->_runsql($sql) };
  8         61  
  15         32  
127 15         148 return \@res;
128             }
129              
130             # Given an account guid,
131             # Return the balance at that guid, ignoring child accounts if any.
132             sub _node_bal {
133 14     14   34 my $self = shift;
134 14         21 my $guid = shift;
135              
136 14         33 my $sql = "SELECT printf('%.2f',SUM(value_num/(value_denom*1.0))) FROM splits "
137             . "WHERE account_guid = ?";
138 14   50     36 return $self->_runsql($sql,$guid)->[0][0] || 0;
139             }
140              
141             # Recursive accumulator
142             sub _guid_bal {
143 13     13   33 my $self = shift;
144 13         25 my $guid = shift;
145 13   50     63 my $bal = shift || 0;
146              
147             # Accumulate balances in child accounts
148 13         24 foreach my $g (@{$self->child_guid($guid)}) {
  13         40  
149 6         28 $bal += $self->_guid_bal($g);
150             }
151            
152             # Add balance in node and return
153 13         54 return $bal + $self->_node_bal($guid);
154             }
155              
156             # Given an account name,
157             # Return the balance in that account, include child accounts, if any
158             sub account_balance {
159 6     6 1 31 my $self = shift;
160 6         14 my $acct_name = shift;
161            
162 6         24 my $guid = $self->account_guid($acct_name);
163 6 100       35 return undef unless defined ($guid);
164 5         35 return $self->_guid_bal($guid);
165             }
166              
167             # Add a transaction to the GnuCash.
168             # Transaction is a hashref e.g.:
169             #
170             # my $txn = {
171             # date => '20140102',
172             # description => 'Deposit monthly savings',
173             # from_account => 'Assets:Cash',
174             # to_account => 'Assets:aBank',
175             # amount => 2540.15,
176             # number => ''
177             # };
178             #
179             # To effect the transaction, do the following:
180             # 1. Add 1 row to transactions table
181             # 2. Add 2 rows to splits table
182             # 3. Add 1 row to slots table
183             # See
184             # http://wideopenstudy.blogspot.com/2014/11/how-to-add-transaction-programmatically.html
185             sub add_transaction {
186 1     1 1 842 my $self = shift;
187 1         2 my $txn = shift;
188              
189             # augment the transaction with needed data
190 1         3 $txn = $self->_augment($txn);
191              
192             # List the SQLs
193 1         2 my $txn_sql = 'INSERT INTO transactions VALUES (?,?,?,?,?,?)';
194 1         3 my $splt_sql = 'INSERT INTO splits VALUES '
195             . ' (?,?,?,"","","n","",?,100,?,100,null)';
196 1         3 my $slot_sql = 'INSERT INTO slots (obj_guid,name,slot_type,int64_val,'
197             . ' string_val,double_val,timespec_val,'
198             . ' guid_val,numeric_val_num,'
199             . ' numeric_val_denom,gdate_val) '
200             . 'VALUES (?,"date-posted",10,0,"",0.0,"","",0,1,?)';
201             # This SQL form because slots has auto-increment field
202              
203             # Run the SQLs
204 1         1 $self->_runsql($txn_sql, map { $txn->{$_} }
  6         12  
205             qw/tx_guid tx_ccy_guid number tx_post_date tx_enter_date
206             description /);
207 1         7 $self->_runsql($splt_sql, map { $txn->{$_} }
  5         19  
208             qw/splt_guid_1 tx_guid tx_from_guid tx_from_numer tx_from_numer/);
209 1         5 $self->_runsql($splt_sql, map { $txn->{$_} }
  5         20  
210             qw/splt_guid_2 tx_guid tx_to_guid tx_to_numer tx_to_numer/);
211 1         7 $self->_runsql($slot_sql, map { $txn->{$_} }
  2         11  
212             qw/tx_guid date/);
213             }
214              
215             # Augment the transaction with data required to generate data rows
216             sub _augment {
217 2     2   90 my $self = shift;
218 2         3 my $txn_orig = shift;
219              
220             # Make a copy of the original transaction so as not to clobber it
221             # Copy only the fields needed
222 2         3 my $txn = {};
223 2         5 map { $txn->{$_} = $txn_orig->{$_} } (
  12         23  
224             qw/date description from_account to_account amount number/);
225              
226 2         6 $txn->{tx_guid} = $self->create_guid();
227 2         7 $txn->{tx_ccy_guid} = $self->commodity_guid($txn->{from_account});
228 2         7 $txn->{tx_post_date} = $self->UTC_post_date($txn->{date});
229 2         48 $txn->{tx_enter_date} = $self->UTC_enter_date();
230 2         63 $txn->{tx_from_guid} = $self->account_guid($txn->{from_account});
231 2         8 $txn->{tx_to_guid} = $self->account_guid($txn->{to_account});
232 2         7 $txn->{tx_from_numer} = $txn->{amount} * -100;
233 2         6 $txn->{tx_to_numer} = $txn->{amount} * 100;
234 2         5 $txn->{splt_guid_1} = $self->create_guid();
235 2         5 $txn->{splt_guid_2} = $self->create_guid();
236              
237 2         5 return $txn;
238             }
239              
240             # Return 1 if Gnucash database is locked,
241             # Return 0 if no other application has locked the database.
242             sub is_locked {
243 2     2 0 9 my $self = shift;
244 2         7 my $sql = "SELECT count(*) FROM gnclock";
245 2 100       10 return $self->_runsql($sql)->[0][0] == 0 ? 0 : 1;
246             }
247              
248             # Given an SQL statement and optionally a list of arguments
249             # execute the SQL with those arguments
250             sub _runsql {
251 55     55   81 my $self = shift;
252 55         134 my ($sql,@args) = @_;
253              
254 55         350 my $sth = $self->{dbh}->prepare($sql);
255 55         100988 $sth->execute(@args);
256 55         1281 my $data = $sth->fetchall_arrayref();
257 55         270 $sth->finish;
258              
259 55         1931 return $data;
260             }
261              
262             1;
263             __END__
264             # Below is stub documentation for your module. You'd better edit it!
265              
266             =head1 SYNOPSIS
267              
268             use GnuCash::SQLite;
269              
270             # create the book
271             $book = GnuCash::SQLite->new(db => 'my_accounts.gnucash');
272              
273             # get account balances
274             $on_hand = $book->account_balance('Assets:Cash');
275             $total = $book->account_balance('Assets');
276              
277             # check if book is locked by another application
278             die "Book is currently used by another application."
279             if $book->is_locked;
280              
281             # add a transaction
282             $book->add_transaction({
283             date => '20140102',
284             description => 'Deposit monthly savings',
285             from_account => 'Assets:Cash',
286             to_account => 'Assets:aBank',
287             amount => 2540.15,
288             number => ''
289             });
290              
291             # access internal GUIDs
292             $book->account_guid('Assets:Cash'); # GUID of account
293             $book->commodity_guid('Assets:Cash'); # GUID of currency
294              
295             =head1 DESCRIPTION
296              
297             GnuCash::SQLite provides an API to read account balances and write
298             transactions against a GnuCash set of accounts (only SQLite3 backend
299             supported).
300              
301             When using the module, always provide account names in full e.g. "Assets:Cash"
302             rather than just "Cash". This lets the module distinguish between accounts
303             with the same name but different parents e.g. Assets:Misc and
304             Expenses:Misc
305              
306             =head1 METHODS
307              
308             =head2 Constructor
309              
310             $book = GnuCash::SQLite->new(db => 'my_account.gnucash');
311              
312             Returns a new C<GnuCash::SQLite> object that accesses a GnuCash with and
313             SQLite backend. The module assumes you have already created a GnuCash file
314             with an SQLite backend and that is the file that should be passed as the
315             parameter.
316              
317             If no file parameter is passed, or if the file is missing, the program will
318             terminate.
319              
320             =head2 account_balance
321              
322             $book->account_balance('Assets:Cash'); # always provide account names in full
323             $book->account_balance('Assets'); # includes child accounts e.g. Assets:Cash
324              
325             Given an account name, return the balance in the account. Account names must
326             be provided in full to distinguish between accounts with the same name but
327             different parents e.g. Assets:Alice:Cash and Assets:Bob:Cash
328              
329             If a parent account name is provided, the total balance, which includes all
330             children accounts, will be returned.
331              
332             =head2 add_transaction
333              
334             $deposit = {
335             date => '20140102',
336             description => 'Deposit monthly savings',
337             from_account => 'Assets:Cash',
338             to_account => 'Assets:aBank',
339             amount => 2540.15,
340             number => ''
341             };
342             $book->add_transaction($deposit);
343              
344             A transaction is defined to have the fields as listed in the example above.
345             All fields are mandatory and hopefully self-explanatory. Constraints on some
346             of the fields are listed below:
347              
348             date Date of the transaction. Formatted as YYYYMMDD.
349             from_account Full account name required.
350             to_account Full account name required.
351              
352              
353             =head1 CAVEATS/LIMITATIONS
354              
355             Some things to be aware of:
356              
357             1. You should have created a GnuCash file with an SQLite backend already
358             2. Module accesses the GnuCash SQLite3 db directly; i.e. use at your own risk.
359             3. Only transactions between Asset accounts have been tested.
360             4. Only two (2) splits for each transaction will be created
361              
362             This module works with GnuCash v2.4.13 on Linux.
363              
364             =head1 SEE ALSO
365              
366             GnuCash wiki pages includes a section on C API and a section on Python
367             bindings which may be of interest.
368              
369             C API : http://wiki.gnucash.org/wiki/C_API
370             Python bindings: http://wiki.gnucash.org/wiki/Python_Bindings
371              
372             This module does not rely on the C API (maybe it should). Instead it relies on
373             some reverse engineering work to understand the changes a transaction makes
374             to the sqlite database. See
375             http://wideopenstudy.blogspot.com/search/label/GnuCash for details.
376              
377             =head1 SUPPORT
378              
379             =head2 Bugs / Feature Requests
380              
381             Please report any bugs or feature requests through the issue tracker at
382             L<https://github.com/hoekit/GnuCash-SQLite/issues>. You will be notified
383             automatically of any progress on your issue.
384              
385             =head2 Source Code
386              
387             This is open source software. The code repository is available for public
388             review and contribution under the terms of the license.
389              
390             <https://github.com/hoekit/GnuCash-SQLite>
391              
392             git clone git@github.com:hoekit/GnuCash-SQLite.git
393              
394             =head1 CREDITS
395              
396             Credit goes to L<Sawyer X|https://metacpan.org/author/XSAWYERX> for fixing long-standing floating-point bug.
397              
398             =head1 AUTHOR
399              
400             Hoe Kit CHEW, E<lt>hoekit at gmail.comE<gt>
401              
402             =head1 COPYRIGHT AND LICENSE
403              
404             Copyright (C) 2014 by Chew Hoe Kit
405              
406             This library is free software; you can redistribute it and/or modify
407             it under the same terms as Perl itself, either Perl version 5.10.0 or,
408             at your option, any later version of Perl 5 you may have available.
409              
410             =cut