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