1 package File
::HomeBank
;
2 # ABSTRACT: Parse HomeBank files
8 use App
::HomeBank2Ledger
::Util
qw(commify);
9 use Exporter
qw(import);
10 use Scalar
::Util
qw(refaddr);
13 use XML
::Parser
::Lite
;
15 our $VERSION = '0.005'; # VERSION
17 our @EXPORT_OK = qw(parse_string parse_file);
41 my %CURRENCY_FLAGS = (
44 my %CATEGORY_FLAGS = (
51 my %TRANSACTION_FLAGS = (
62 my %TRANSACTION_STATUSES = (
69 my %TRANSACTION_PAYMODES = (
75 5 => 'internaltransfer',
84 sub _croak
{ require Carp
; Carp
::croak
(@_) }
85 sub _usage
{ _croak
("Usage: @_\n") }
96 if (my $filepath = $args{file
}) {
97 $self = parse_file
($filepath);
98 $self->{file
} = $filepath;
100 elsif (my $str = $args{string
}) {
101 $self = parse_string
($str);
104 _usage
(q{File::HomeBank->new(string => $str)});
107 return bless $self, $class;
112 my $in_global_destruction = shift;
113 delete $CACHE{refaddr
($self)} if !$in_global_destruction;
123 shift-
>{properties
}{title
};
128 shift-
>{properties
}{currency
};
132 sub accounts
{ shift-
>{accounts
} || [] }
133 sub categories
{ shift-
>{categories
} || [] }
134 sub currencies
{ shift-
>{currencies
} || [] }
135 sub payees
{ shift-
>{payees
} || [] }
136 sub transactions
{ shift-
>{transactions
} || [] }
143 for my $transaction (@{$self->transactions}) {
144 for my $tag (split(/\h+/, $transaction->{tags
} || '')) {
153 sub find_account_by_key
{
155 my $key = shift or return;
157 my $index = $CACHE{refaddr
($self)}{account_by_key
};
160 for my $account (@{$self->accounts}) {
161 $index->{$account->{key
}} = $account;
164 $CACHE{refaddr
($self)}{account_by_key
} = $index;
167 return $index->{$key};
171 sub find_currency_by_key
{
173 my $key = shift or return;
175 my $index = $CACHE{refaddr
($self)}{currency_by_key
};
178 for my $currency (@{$self->currencies}) {
179 $index->{$currency->{key
}} = $currency;
182 $CACHE{refaddr
($self)}{currency_by_key
} = $index;
185 return $index->{$key};
189 sub find_category_by_key
{
191 my $key = shift or return;
193 my $index = $CACHE{refaddr
($self)}{category_by_key
};
196 for my $category (@{$self->categories}) {
197 $index->{$category->{key
}} = $category;
200 $CACHE{refaddr
($self)}{category_by_key
} = $index;
203 return $index->{$key};
207 sub find_payee_by_key
{
209 my $key = shift or return;
211 my $index = $CACHE{refaddr
($self)}{payee_by_key
};
214 for my $payee (@{$self->payees}) {
215 $index->{$payee->{key
}} = $payee;
218 $CACHE{refaddr
($self)}{payee_by_key
} = $index;
221 return $index->{$key};
225 sub find_transactions_by_transfer_key
{
227 my $key = shift or return;
229 my $index = $CACHE{refaddr
($self)}{transactions_by_transfer_key
};
232 for my $transaction (@{$self->transactions}) {
233 my $xfkey = $transaction->{transfer_key
} or next;
234 push @{$index->{$xfkey} ||= []}, $transaction;
237 $CACHE{refaddr
($self)}{transactions_by_transfer_key
} = $index;
240 return @{$index->{$key} || []};
244 sub find_transaction_transfer_pair
{
246 my $transaction = shift;
248 return if $transaction->{paymode
} ne 'internaltransfer';
250 my $transfer_key = $transaction->{transfer_key
};
252 my @matching = grep { refaddr
($_) != refaddr
($transaction) }
253 $self->find_transactions_by_transfer_key($transfer_key);
254 warn "Found more than two transactions with the same transfer key.\n" if 1 < @matching;
255 return $matching[0] if @matching;
257 warn "Found internal transfer with no tranfer key.\n" if !defined $transfer_key;
259 my $dst_account = $self->find_account_by_key($transaction->{dst_account
});
261 warn "Found internal transfer with no destination account.\n";
267 for my $t (@{$self->transactions}) {
268 next if $t->{paymode
} ne 'internaltransfer';
269 next if $t->{account
} != $transaction->{dst_account
};
270 next if $t->{dst_account
} != $transaction->{account
};
271 next if $t->{amount
} != -$transaction->{amount
};
273 my @matching = $self->find_transactions_by_transfer_key($t->{transfer_key
});
274 next if 1 < @matching; # other transaction must also be orphaned
276 push @candidates, $t;
279 my $transaction_day = _ymd_to_julian
($transaction->{date
});
281 # sort the candidates so we can pick the nearest one by date
282 my @ordered_candidates =
284 sort { $a->[0] <=> $b->[0] }
285 map { [abs($transaction_day - _ymd_to_julian
($_->{date
})), $_] } @candidates;
287 if (my $winner = $ordered_candidates[0]) {
288 my $key1 = $transfer_key || '[no key]';
289 my $key2 = $winner->{transfer_key
} || '[no key]';
290 warn "Paired orphaned internal transfer ${key1} and ${key2}.\n";
296 sub sorted_transactions
{
299 my $sorted_transactions = $CACHE{refaddr
($self)}{sorted_transactions
};
300 if (!$sorted_transactions) {
301 $sorted_transactions = [sort { $a->{date
} cmp $b->{date
} } @{$self->transactions}];
303 $CACHE{refaddr
($self)}{sorted_transactions
} = $sorted_transactions;
306 return $sorted_transactions;
310 sub full_category_name
{
312 my $key = shift or return;
314 my $cat = $self->find_category_by_key($key);
316 my @categories = ($cat);
318 while (my $parent_key = $cat->{parent
}) {
319 $cat = $self->find_category_by_key($parent_key);
320 unshift @categories, $cat;
323 return join(':', map { $_->{name
} } @categories);
330 my $currency = shift || $self->base_currency;
332 $currency = $self->find_currency_by_key($currency) if !ref($currency);
333 _croak
'Must provide a valid currency' if !$currency;
335 my $format = "\% .$currency->{frac}f";
336 my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
338 my $num = join($currency->{dchar
}, commify
($whole, $currency->{gchar
}), $fraction);
340 $num = $currency->{syprf
} ? "$currency->{symbol} $num" : "$num $currency->{symbol}";
347 my $filepath = shift or _usage
(q{parse_file($filepath)});
349 open(my $fh, '<', $filepath) or die "open failed: $!";
350 my $str_in = do { local $/; <$fh> };
352 return parse_string
($str_in);
357 my $str = shift or die _usage
(q{parse_string($str)});
366 my $xml_parser = XML
::Parser
::Lite-
>new(
373 # decode all attribute values
374 for my $key (keys %attr) {
375 $attr{$key} = _decode_xml_entities
($attr{$key});
378 if ($node eq 'properties') {
379 $attr{currency
} = delete $attr{curr
} if $attr{curr
};
382 elsif ($node eq 'account') {
383 $attr{type
} = $ACCOUNT_TYPES{$attr{type
} || ''} || 'unknown';
384 $attr{bank_name
} = delete $attr{bankname
} if $attr{bankname
};
385 $attr{currency
} = delete $attr{curr
} if $attr{curr
};
386 $attr{display_position
} = delete $attr{pos} if $attr{pos};
388 my $flags = delete $attr{flags
} || 0;
389 while (my ($shift, $name) = each %ACCOUNT_FLAGS) {
390 $attr{flags
}{$name} = $flags & (1 << $shift) ? 1 : 0;
393 push @accounts, \
%attr;
395 elsif ($node eq 'pay') { # payee
396 push @payees, \
%attr;
398 elsif ($node eq 'cur') { # currency
399 $attr{symbol
} = delete $attr{symb
} if $attr{symb
};
401 my $flags = delete $attr{flags
} || 0;
402 while (my ($shift, $name) = each %CURRENCY_FLAGS) {
403 $attr{flags
}{$name} = $flags & (1 << $shift) ? 1 : 0;
406 push @currencies, \
%attr;
408 elsif ($node eq 'cat') { # category
409 my $flags = delete $attr{flags
} || 0;
410 while (my ($shift, $name) = each %CATEGORY_FLAGS) {
411 $attr{flags
}{$name} = $flags & (1 << $shift) ? 1 : 0;
414 for my $bnum (0 .. 12) {
415 $attr{budget_amounts
}[$bnum] = delete $attr{"b$bnum"} if $attr{"b$bnum"};
418 push @categories, \
%attr;
420 elsif ($node eq 'ope') { # transaction
421 $attr{paymode
} = $TRANSACTION_PAYMODES{$attr{paymode
} || ''} || 'unknown';
422 $attr{status
} = $TRANSACTION_STATUSES{delete $attr{st
}} || 'unknown';
424 $attr{transfer_key
} = delete $attr{kxfer
} if $attr{kxfer
};
425 $attr{split_amount
} = delete $attr{samt
} if $attr{samt
};
426 $attr{split_memo
} = delete $attr{smem
} if $attr{smem
};
427 $attr{split_category
} = delete $attr{scat
} if $attr{scat
};
429 $attr{date
} = _rdn_to_ymd
($attr{date
}) if $attr{date
};
431 my $flags = delete $attr{flags
} || 0;
432 while (my ($shift, $name) = each %TRANSACTION_FLAGS) {
433 $attr{flags
}{$name} = $flags & (1 << $shift) ? 1 : 0;
436 push @transactions, \
%attr;
441 $xml_parser->parse($str);
444 properties
=> \
%properties,
445 accounts
=> \
@accounts,
447 categories
=> \
@categories,
448 currencies
=> \
@currencies,
449 transactions
=> \
@transactions,
453 sub _decode_xml_entities
{
455 # decoding entities can be extremely slow, so don't bother if it doesn't look like there are any
457 return $str if $str !~ /&(?:#\d+)|[A-Za-z0-9]+;/;
458 return XML
::Entities
::decode
('all', $str);
461 sub _rdn_to_unix_epoch
{
463 my $jan01_1970 = 719163;
464 return ($rdn - $jan01_1970) * 86400;
469 my $epoch = _rdn_to_unix_epoch
($rdn);
470 my $time = gmtime($epoch);
476 my $t = Time
::Piece-
>strptime($ymd, '%Y-%m-%d');
477 return $t->julian_day;
490 File::HomeBank - Parse HomeBank files
500 use File::HomeBank qw(parse_file);
502 my $raw_data = parse_file('path/to/homebank.xhb');
506 my $homebank = File::HomeBank->new(file => 'path/to/homebank.xhb');
508 for my $account (@{$homebank->accounts}) {
509 print "Found account named $account->{name}\n";
514 This module parses L<HomeBank|http://homebank.free.fr/> files.
520 Get the filepath (if parsed from a file).
526 $homebank = File::HomeBank->new(string => $str);
527 $homebank = File::HomeBank->new(file => $filepath);
529 Construct a L<File::HomeBank>.
533 $title = $homebank->title;
535 Get the title or owner property.
539 $base_currency = $homebank->base_currency;
541 Get the key of the base currency.
545 Get an arrayref of accounts.
549 Get an arrayref of categories.
553 Get an arrayref of currencies.
557 Get an arrayref of payees.
561 Get an arrayref of tags.
565 Get an arrayref of transactions.
567 =head2 find_account_by_key
569 $account = $homebank->find_account_by_key($key);
571 Find a account with the given key.
573 =head2 find_currency_by_key
575 $currency = $homebank->find_currency_by_key($key);
577 Find a currency with the given key.
579 =head2 find_category_by_key
581 $category = $homebank->find_category_by_key($key);
583 Find a category with the given key.
585 =head2 find_payee_by_key
587 $payee = $homebank->find_payee_by_key($key);
589 Find a payee with the given key.
591 =head2 find_transactions_by_transfer_key
593 @transactions = $homebank->find_transactions_by_transfer_key($key);
595 Find all transactions that share the same transfer key.
597 =head2 find_transaction_transfer_pair
599 $other_transaction = $homebank->find_transaction_transfer_pair($transaction);
601 Given a transaction hashref, return its corresponding transaction if it is an internal transfer. If
602 the transaction is an internal transaction with a destination account but is orphaned (has no
603 matching transfer key), this also looks for another orphaned transaction in the destination account
604 that it can call its partner.
606 Returns undef or empty if no corresponding transaction is found.
608 =head2 sorted_transactions
610 $transations = $homebank->sorted_transactions;
612 Get an arrayref of transactions sorted by date (oldest first).
614 =head2 full_category_name
616 $category_name = $homebank->full_category_name($key);
618 Generate the full name for a category, taking category inheritance into consideration.
629 $formatted_amount = $homebank->format_amount($amount);
630 $formatted_amount = $homebank->format_amount($amount, $currency);
632 Formats an amount in either the base currency (for the whole file) or in the given currency.
633 Currency can be a key or the actualy currency structure.
639 $homebank_data = parse_file($filepath);
641 Read and parse a HomeBank .xhb file from a filesystem.
645 $homebank_data = parse_string($str);
647 Parse a HomeBank file from a string.
651 Please report any bugs or feature requests on the bugtracker website
652 L<https://github.com/chazmcgarvey/homebank2ledger/issues>
654 When submitting a bug or request, please include a test-file or a
655 patch to an existing test-file that illustrates the bug or desired
660 Charles McGarvey <chazmcgarvey@brokenzipper.com>
662 =head1 COPYRIGHT AND LICENSE
664 This software is Copyright (c) 2019 by Charles McGarvey.
666 This is free software, licensed under:
668 The MIT (X11) License