--- /dev/null
+Revision history for homebank2ledger.
+
+0.001 2019-06-12 22:54:58-06:00 MST7MDT
+ * Initial early development release
+
--- /dev/null
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+The MIT License
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to
+whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall
+be included in all copies or substantial portions of the
+Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT
+WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR
+PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
--- /dev/null
+# This file was automatically generated by Dist::Zilla::Plugin::Manifest v6.012.
+Changes
+LICENSE
+MANIFEST
+META.json
+META.yml
+Makefile.PL
+README
+bin/homebank2ledger
+lib/App/HomeBank2Ledger.pm
+lib/App/HomeBank2Ledger/Formatter.pm
+lib/App/HomeBank2Ledger/Formatter/Beancount.pm
+lib/App/HomeBank2Ledger/Formatter/Ledger.pm
+lib/App/HomeBank2Ledger/Ledger.pm
+lib/App/HomeBank2Ledger/Util.pm
+lib/File/HomeBank.pm
+t/00-compile.t
+t/00-report-prereqs.dd
+t/00-report-prereqs.t
+xt/author/critic.t
+xt/author/eol.t
+xt/author/minimum-version.t
+xt/author/no-tabs.t
+xt/author/pod-coverage.t
+xt/author/pod-no404s.t
+xt/author/pod-syntax.t
+xt/author/portability.t
+xt/release/consistent-version.t
+xt/release/cpan-changes.t
+xt/release/distmeta.t
--- /dev/null
+{
+ "abstract" : "A tool to convert HomeBank files to Ledger format",
+ "author" : [
+ "Charles McGarvey <chazmcgarvey@brokenzipper.com>"
+ ],
+ "dynamic_config" : 0,
+ "generated_by" : "Dist::Zilla version 6.012, CPAN::Meta::Converter version 2.150010",
+ "license" : [
+ "mit"
+ ],
+ "meta-spec" : {
+ "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec",
+ "version" : 2
+ },
+ "name" : "homebank2ledger",
+ "no_index" : {
+ "directory" : [
+ "eg",
+ "share",
+ "shares",
+ "t",
+ "xt"
+ ]
+ },
+ "prereqs" : {
+ "configure" : {
+ "requires" : {
+ "ExtUtils::MakeMaker" : "0"
+ }
+ },
+ "develop" : {
+ "requires" : {
+ "Dist::Zilla" : "5",
+ "Dist::Zilla::Plugin::ConsistentVersionTest" : "0",
+ "Dist::Zilla::PluginBundle::Author::CCM" : "0",
+ "Dist::Zilla::PluginBundle::Filter" : "0",
+ "Pod::Coverage::TrustPod" : "0",
+ "Software::License::MIT" : "0",
+ "Test::CPAN::Changes" : "0.19",
+ "Test::CPAN::Meta" : "0",
+ "Test::ConsistentVersion" : "0",
+ "Test::EOL" : "0",
+ "Test::MinimumVersion" : "0",
+ "Test::More" : "0.96",
+ "Test::NoTabs" : "0",
+ "Test::Perl::Critic" : "0",
+ "Test::Pod" : "1.41",
+ "Test::Pod::Coverage" : "1.08",
+ "Test::Pod::No404s" : "0",
+ "Test::Portability::Files" : "0"
+ }
+ },
+ "runtime" : {
+ "requires" : {
+ "Carp" : "0",
+ "Exporter" : "0",
+ "Getopt::Long" : "2.38",
+ "Module::Load" : "0",
+ "Module::Pluggable" : "0",
+ "Pod::Usage" : "0",
+ "Scalar::Util" : "0",
+ "Time::Piece" : "0",
+ "XML::Entities" : "0",
+ "XML::Parser::Lite" : "0",
+ "parent" : "0",
+ "strict" : "0",
+ "warnings" : "0"
+ }
+ },
+ "test" : {
+ "recommends" : {
+ "CPAN::Meta" : "2.120900"
+ },
+ "requires" : {
+ "ExtUtils::MakeMaker" : "0",
+ "File::Spec" : "0",
+ "IO::Handle" : "0",
+ "IPC::Open3" : "0",
+ "Test::More" : "0",
+ "perl" : "5.006"
+ }
+ }
+ },
+ "provides" : {
+ "App::HomeBank2Ledger" : {
+ "file" : "lib/App/HomeBank2Ledger.pm",
+ "version" : "0.001"
+ },
+ "App::HomeBank2Ledger::Formatter" : {
+ "file" : "lib/App/HomeBank2Ledger/Formatter.pm",
+ "version" : "0.001"
+ },
+ "App::HomeBank2Ledger::Formatter::Beancount" : {
+ "file" : "lib/App/HomeBank2Ledger/Formatter/Beancount.pm",
+ "version" : "0.001"
+ },
+ "App::HomeBank2Ledger::Formatter::Ledger" : {
+ "file" : "lib/App/HomeBank2Ledger/Formatter/Ledger.pm",
+ "version" : "0.001"
+ },
+ "App::HomeBank2Ledger::Ledger" : {
+ "file" : "lib/App/HomeBank2Ledger/Ledger.pm",
+ "version" : "0.001"
+ },
+ "App::HomeBank2Ledger::Util" : {
+ "file" : "lib/App/HomeBank2Ledger/Util.pm",
+ "version" : "0.001"
+ },
+ "File::HomeBank" : {
+ "file" : "lib/File/HomeBank.pm",
+ "version" : "0.001"
+ }
+ },
+ "release_status" : "stable",
+ "resources" : {
+ "bugtracker" : {
+ "web" : "https://github.com/chazmcgarvey/homebank2ledger/issues"
+ },
+ "homepage" : "https://github.com/chazmcgarvey/homebank2ledger",
+ "repository" : {
+ "type" : "git",
+ "url" : "https://github.com/chazmcgarvey/homebank2ledger.git",
+ "web" : "https://github.com/chazmcgarvey/homebank2ledger"
+ }
+ },
+ "version" : "0.001",
+ "x_authority" : "cpan:CCM",
+ "x_generated_by_perl" : "v5.28.0",
+ "x_serialization_backend" : "Cpanel::JSON::XS version 4.08"
+}
+
--- /dev/null
+---
+abstract: 'A tool to convert HomeBank files to Ledger format'
+author:
+ - 'Charles McGarvey <chazmcgarvey@brokenzipper.com>'
+build_requires:
+ ExtUtils::MakeMaker: '0'
+ File::Spec: '0'
+ IO::Handle: '0'
+ IPC::Open3: '0'
+ Test::More: '0'
+ perl: '5.006'
+configure_requires:
+ ExtUtils::MakeMaker: '0'
+dynamic_config: 0
+generated_by: 'Dist::Zilla version 6.012, CPAN::Meta::Converter version 2.150010'
+license: mit
+meta-spec:
+ url: http://module-build.sourceforge.net/META-spec-v1.4.html
+ version: '1.4'
+name: homebank2ledger
+no_index:
+ directory:
+ - eg
+ - share
+ - shares
+ - t
+ - xt
+provides:
+ App::HomeBank2Ledger:
+ file: lib/App/HomeBank2Ledger.pm
+ version: '0.001'
+ App::HomeBank2Ledger::Formatter:
+ file: lib/App/HomeBank2Ledger/Formatter.pm
+ version: '0.001'
+ App::HomeBank2Ledger::Formatter::Beancount:
+ file: lib/App/HomeBank2Ledger/Formatter/Beancount.pm
+ version: '0.001'
+ App::HomeBank2Ledger::Formatter::Ledger:
+ file: lib/App/HomeBank2Ledger/Formatter/Ledger.pm
+ version: '0.001'
+ App::HomeBank2Ledger::Ledger:
+ file: lib/App/HomeBank2Ledger/Ledger.pm
+ version: '0.001'
+ App::HomeBank2Ledger::Util:
+ file: lib/App/HomeBank2Ledger/Util.pm
+ version: '0.001'
+ File::HomeBank:
+ file: lib/File/HomeBank.pm
+ version: '0.001'
+requires:
+ Carp: '0'
+ Exporter: '0'
+ Getopt::Long: '2.38'
+ Module::Load: '0'
+ Module::Pluggable: '0'
+ Pod::Usage: '0'
+ Scalar::Util: '0'
+ Time::Piece: '0'
+ XML::Entities: '0'
+ XML::Parser::Lite: '0'
+ parent: '0'
+ strict: '0'
+ warnings: '0'
+resources:
+ bugtracker: https://github.com/chazmcgarvey/homebank2ledger/issues
+ homepage: https://github.com/chazmcgarvey/homebank2ledger
+ repository: https://github.com/chazmcgarvey/homebank2ledger.git
+version: '0.001'
+x_authority: cpan:CCM
+x_generated_by_perl: v5.28.0
+x_serialization_backend: 'YAML::Tiny version 1.73'
--- /dev/null
+# This file was automatically generated by Dist::Zilla::Plugin::MakeMaker v6.012.
+use strict;
+use warnings;
+
+use 5.006;
+
+use ExtUtils::MakeMaker;
+
+my %WriteMakefileArgs = (
+ "ABSTRACT" => "A tool to convert HomeBank files to Ledger format",
+ "AUTHOR" => "Charles McGarvey <chazmcgarvey\@brokenzipper.com>",
+ "CONFIGURE_REQUIRES" => {
+ "ExtUtils::MakeMaker" => 0
+ },
+ "DISTNAME" => "homebank2ledger",
+ "EXE_FILES" => [
+ "bin/homebank2ledger"
+ ],
+ "LICENSE" => "mit",
+ "MIN_PERL_VERSION" => "5.006",
+ "NAME" => "homebank2ledger",
+ "PREREQ_PM" => {
+ "Carp" => 0,
+ "Exporter" => 0,
+ "Getopt::Long" => "2.38",
+ "Module::Load" => 0,
+ "Module::Pluggable" => 0,
+ "Pod::Usage" => 0,
+ "Scalar::Util" => 0,
+ "Time::Piece" => 0,
+ "XML::Entities" => 0,
+ "XML::Parser::Lite" => 0,
+ "parent" => 0,
+ "strict" => 0,
+ "warnings" => 0
+ },
+ "TEST_REQUIRES" => {
+ "ExtUtils::MakeMaker" => 0,
+ "File::Spec" => 0,
+ "IO::Handle" => 0,
+ "IPC::Open3" => 0,
+ "Test::More" => 0
+ },
+ "VERSION" => "0.001",
+ "test" => {
+ "TESTS" => "t/*.t"
+ }
+);
+
+
+my %FallbackPrereqs = (
+ "Carp" => 0,
+ "Exporter" => 0,
+ "ExtUtils::MakeMaker" => 0,
+ "File::Spec" => 0,
+ "Getopt::Long" => "2.38",
+ "IO::Handle" => 0,
+ "IPC::Open3" => 0,
+ "Module::Load" => 0,
+ "Module::Pluggable" => 0,
+ "Pod::Usage" => 0,
+ "Scalar::Util" => 0,
+ "Test::More" => 0,
+ "Time::Piece" => 0,
+ "XML::Entities" => 0,
+ "XML::Parser::Lite" => 0,
+ "parent" => 0,
+ "strict" => 0,
+ "warnings" => 0
+);
+
+
+unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
+ delete $WriteMakefileArgs{TEST_REQUIRES};
+ delete $WriteMakefileArgs{BUILD_REQUIRES};
+ $WriteMakefileArgs{PREREQ_PM} = \%FallbackPrereqs;
+}
+
+delete $WriteMakefileArgs{CONFIGURE_REQUIRES}
+ unless eval { ExtUtils::MakeMaker->VERSION(6.52) };
+
+WriteMakefile(%WriteMakefileArgs);
--- /dev/null
+NAME
+
+ App::HomeBank2Ledger - A tool to convert HomeBank files to Ledger
+ format
+
+VERSION
+
+ version 0.001
+
+SYNOPSIS
+
+ App::HomeBank2Ledger->main(@args);
+
+DESCRIPTION
+
+ This module is part of the homebank2ledger script.
+
+METHODS
+
+ main
+
+ App::HomeBank2Ledger->main(@args);
+
+ Run the script and exit; does not return.
+
+ formatter
+
+ $formatter = $app->formatter($homebank, $opts);
+
+ Generate a App::HomeBank2Ledger::Formatter.
+
+ convert_homebank_to_ledger
+
+ my $ledger = $app->convert_homebank_to_ledger($homebank, $opts);
+
+ Converts a File::HomeBank to a App::HomeBank2Ledger::Ledger.
+
+ print_to_file
+
+ $app->print_to_file($str);
+ $app->print_to_file($str, $filepath);
+
+ Print a string to a file (or STDOUT).
+
+ parse_args
+
+ $opts = $app->parse_args(@args);
+
+ Parse command-line arguments.
+
+BUGS
+
+ Please report any bugs or feature requests on the bugtracker website
+ https://github.com/chazmcgarvey/homebank2ledger/issues
+
+ When submitting a bug or request, please include a test-file or a patch
+ to an existing test-file that illustrates the bug or desired feature.
+
+AUTHOR
+
+ Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+COPYRIGHT AND LICENSE
+
+ This software is Copyright (c) 2019 by Charles McGarvey.
+
+ This is free software, licensed under:
+
+ The MIT (X11) License
+
--- /dev/null
+#! perl
+# ABSTRACT: A tool to convert HomeBank files to Ledger format
+# PODNAME: homebank2ledger
+
+
+use warnings;
+use strict;
+
+use App::HomeBank2Ledger;
+
+our $VERSION = '0.001'; # VERSION
+
+App::HomeBank2Ledger->main(@ARGV);
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+homebank2ledger - A tool to convert HomeBank files to Ledger format
+
+=head1 VERSION
+
+version 0.001
+
+=head1 SYNOPSIS
+
+ homebank2ledger --input FILEPATH [--output FILEPATH]
+ [--version|--help|--manual]
+ [--format FORMAT] [--account-width NUM]
+ [--accounts|--no-accounts] [--payees|--no-payees]
+ [--tags|--no-tags] [--commodities|--no-commodities]
+ [--opening-date DATE] [--default-account STR]
+ [--rename-account STR]... [--exclude-account STR]...
+
+=head1 DESCRIPTION
+
+C<homebank2ledger> converts L<HomeBank|http://homebank.free.fr/> files to a format usable by
+L<Ledger|https://www.ledger-cli.org/>. It can also convert directly to the similar
+L<Beancount|http://furius.ca/beancount/> format.
+
+This software is B<EXPERIMENTAL>, in early development. Its interface may change without notice.
+
+I wrote C<homebank2ledger> because I have been maintaining my own personal finances using HomeBank
+(which is awesome) and I wanted to investigate using plain text accounting programs. It works well
+enough for my data, but you may be using HomeBank features that I don't so there may be cases this
+doesn't handle well or at all. Feel free to file a bug report. This script does NOT try to modify
+the original HomeBank files it converts from, so there won't be any crazy data loss bugs... but no
+warranty.
+
+=head2 Features
+
+=over 4
+
+=item *
+
+Converts HomeBank accounts and categories into a typical set of double-entry accounts.
+
+=item *
+
+Retains HomeBank metadata, including payees and tags.
+
+=item *
+
+Offers some customization of the output ledger, like account renaming.
+
+=back
+
+This program is feature-complete in my opinion (well, almost -- see L</CAVEATS>), but if there is
+anything you think it could do to be even better, feedback is welcome; just file a bug report. Or
+fork the code and have fun!
+
+=head2 Use cases
+
+You can migrate the data you have in HomeBank so you can start maintaining your accounts in Ledger
+(or Beancount).
+
+Or if you don't plan to switch completely off of HomeBank, you can continue to maintain your
+accounts in HomeBank and use this script to also take advantage of the reports Ledger offers.
+
+=head1 OPTIONS
+
+=head2 --version
+
+Print the version and exit.
+
+Alias: C<-V>
+
+=head2 --help
+
+Print help/usage info and exit.
+
+Alias: C<-h>, C<-?>
+
+=head2 --manual
+
+Print the full manual and exit.
+
+Alias: C<--man>
+
+=head2 --input FILEPATH
+
+Specify the path to the HomeBank file to read (must already exist).
+
+Alias: C<--file>, C<-i>
+
+=head2 --output FILEPATH
+
+Specify the path to the Ledger file to write (may not exist yet). If not provided, the formatted
+ledger will be printed on C<STDOUT>.
+
+Alias: C<-o>
+
+=head2 --format STR
+
+Specify the output file format. If provided, must be one of:
+
+=over 4
+
+=item *
+
+ledger
+
+=item *
+
+beancount
+
+=back
+
+=head2 --account-width NUM
+
+Specify the number of characters to reserve for the account column in transactions. Adjusting this
+can provide prettier formatting of the output.
+
+Defaults to 40.
+
+=head2 --accounts
+
+Enables account declarations.
+
+Defaults to enabled; use C<--no-accounts> to disable.
+
+=head2 --payees
+
+Enables payee declarations.
+
+Defaults to enabled; use C<--no-payees> to disable.
+
+=head2 --tags
+
+Enables tag declarations.
+
+Defaults to enabled; use C<--no-tags> to disable.
+
+=head2 --commodities
+
+Enables commodity declarations.
+
+Defaults to enabled; use C<--no-commodities> to disable.
+
+=head2 --opening-date DATE
+
+Specify the opening date for the "opening balances" transaction. This transaction is created (if
+needed) to support HomeBank's ability to configure accounts with opening balances.
+
+Date must be in the form "YYYY-MM-DD". Defaults to the date of the first transaction.
+
+=head2 --default-account STR
+
+Specify the account to use for one-sided transactions (if any). Defaults to "Expenses:No Category".
+
+A default account may be necessary because with Ledger all transactions are double-entry.
+
+=head2 --rename-account STR
+
+Specifies a mapping for renaming accounts in the output. By default C<homebank2ledger> tries to come
+up with sensible account names (based on your HomeBank accounts and categories) that fit into five
+root accounts:
+
+=over 4
+
+=item *
+
+Assets
+
+=item *
+
+Liabilities
+
+=item *
+
+Equity
+
+=item *
+
+Income
+
+=item *
+
+Expenses
+
+=back
+
+The value of the argument must be of the form "REGEXP=REPLACEMENT". See L</EXAMPLES>.
+
+Can be repeated to rename multiple accounts.
+
+=head2 --exclude-account STR
+
+Specifies an account that will not be included in the output. All transactions related to this
+account will be skipped.
+
+Can be repeated to exclude multiple accounts.
+
+=head1 EXAMPLES
+
+=head2 Basic usage
+
+ # Convert homebank.xhb to a Ledger-compatible file:
+ homebank2ledger path/to/homebank.xhb -o ledger.dat
+
+ # Run the Ledger balance report:
+ ledger -f ledger.dat balance
+
+You can also combine this into one command:
+
+ homebank2ledger path/to/homebank.xhb | ledger -f - balance
+
+=head2 Account renaming
+
+With the L</"--rename-account STR"> argument, you have some control over the resulting account
+structure. This may be useful in cases where the organization imposed (or encouraged) by HomeBank
+doesn't necessarily line up with an ideal double-entry structure.
+
+ homebank2ledger path/to/homebank.xhb -o ledger.dat \
+ --rename-account '^Assets:Credit Union Savings$=Assets:Bank:Credit Union:Savings' \
+ --rename-account '^Assets:Credit Union Checking$=Assets:Bank:Credit Union:Checking'
+
+Multiple accounts can be renamed at the same time because the first part of the mapping is a regular
+expression. The above example could be written like this:
+
+ homebank2ledger path/to/homebank.xhb -o ledger.dat \
+ --rename-account '^Assets:Credit Union =Assets:Bank:Credit Union:'
+
+You can also merge accounts by simple renaming multiple accounts to the same name:
+
+ homebank2ledger path/to/homebank.xhb -o ledger.dat \
+ --rename-account '^Liabilities:Chase VISA$=Liabilities:All Credit Cards' \
+ --rename-account '^Liabilities:Amex$=Liabilities:All Credit Cards'
+
+If you need to do anything more complicated, of course you can edit the output after converting;
+it's just plain text.
+
+=head2 Beancount
+
+ # Convert homebank.xhb to a Beancount-compatible file:
+ homebank2ledger path/to/homebank.xhb -f beancount -o ledger.beancount
+
+ # Run the balances report:
+ bean-report ledger.beancount balances
+
+=head1 CAVEATS
+
+=over 4
+
+=item *
+
+I didn't intend to make this a releasable robust product, so it's lacking tests.
+
+=item *
+
+Budgets and scheduled transactions are not (yet) converted.
+
+=item *
+
+There are some minor formatting tweaks I will make (e.g. consolidate transaction tags and payees)
+
+=back
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::HomeBank2Ledger;
+# ABSTRACT: A tool to convert HomeBank files to Ledger format
+
+
+use warnings FATAL => 'all'; # temp fatal all
+use strict;
+
+use App::HomeBank2Ledger::Formatter;
+use App::HomeBank2Ledger::Ledger;
+use File::HomeBank;
+use Getopt::Long 2.38 qw(GetOptionsFromArray);
+use Pod::Usage;
+
+our $VERSION = '0.001'; # VERSION
+
+my %ACCOUNT_TYPES = ( # map HomeBank account types to Ledger accounts
+ bank => 'Assets:Bank',
+ cash => 'Assets:Cash',
+ asset => 'Assets:Fixed Assets',
+ creditcard => 'Liabilities:Credit Card',
+ liability => 'Liabilities',
+ stock => 'Assets:Stock',
+ mutualfund => 'Assets:Mutual Fund',
+ income => 'Income',
+ expense => 'Expenses',
+ equity => 'Equity',
+);
+my %STATUS_SYMBOLS = (
+ cleared => 'cleared',
+ reconciled => 'cleared',
+ remind => 'pending',
+);
+my $UNKNOWN_ACCOUNT = 'Assets:Unknown';
+my $OPENING_BALANCES_ACCOUNT = 'Equity:Opening Balances';
+
+
+sub main {
+ my $class = shift;
+ my $self = bless {}, $class;
+
+ my $opts = $self->parse_args(@_);
+
+ if ($opts->{version}) {
+ print "homebank2ledger ${VERSION}\n";
+ exit 0;
+ }
+ if ($opts->{help}) {
+ pod2usage(-exitval => 0, -verbose => 99, -sections => [qw(NAME SYNOPSIS OPTIONS)]);
+ }
+ if ($opts->{manual}) {
+ pod2usage(-exitval => 0, -verbose => 2);
+ }
+
+ my $homebank = File::HomeBank->new(file => $opts->{input});
+
+ my $formatter = eval { $self->formatter($homebank, $opts) };
+ if (my $err = $@) {
+ if ($err =~ /^Invalid formatter/) {
+ print STDERR "Invalid format: $opts->{format}\n";
+ exit 2;
+ }
+ die $err;
+ }
+
+ my $ledger = $self->convert_homebank_to_ledger($homebank, $opts);
+
+ $self->print_to_file($formatter->format($ledger), $opts->{output});
+
+ exit 0;
+}
+
+
+sub formatter {
+ my $self = shift;
+ my $homebank = shift;
+ my $opts = shift || {};
+
+ return App::HomeBank2Ledger::Formatter->new(
+ type => $opts->{format},
+ account_width => $opts->{account_width},
+ name => $homebank->title,
+ file => $homebank->file,
+ );
+}
+
+
+sub convert_homebank_to_ledger {
+ my $self = shift;
+ my $homebank = shift;
+ my $opts = shift || {};
+
+ my $ledger = App::HomeBank2Ledger::Ledger->new;
+
+ my $transactions = $homebank->sorted_transactions;
+ my $accounts = $homebank->accounts;
+ my $categories = $homebank->categories;
+
+ # determine full Ledger account names
+ for my $account (@$accounts) {
+ my $type = $ACCOUNT_TYPES{$account->{type}} || $UNKNOWN_ACCOUNT;
+ $account->{ledger_name} = "${type}:$account->{name}";
+ }
+ for my $category (@$categories) {
+ my $type = $category->{flags}{income} ? 'Income' : 'Expenses';
+ my $full_name = $homebank->full_category_name($category->{key});
+ $category->{ledger_name} = "${type}:${full_name}";
+ }
+
+ # handle renaming and marking excluded accounts
+ for my $item (@$accounts, @$categories) {
+ while (my ($re, $replacement) = each %{$opts->{rename_accounts}}) {
+ $item->{ledger_name} =~ s/$re/$replacement/;
+ }
+ for my $re (@{$opts->{exclude_accounts}}) {
+ $item->{excluded} = 1 if $item->{ledger_name} =~ /$re/;
+ }
+ }
+
+ my $has_initial_balance = grep { $_->{initial} && !$_->{excluded} } @$accounts;
+
+ if ($opts->{accounts}) {
+ my @accounts = map { $_->{ledger_name} } grep { !$_->{excluded} } @$accounts, @$categories;
+
+ push @accounts, $opts->{default_account};
+ push @accounts, $OPENING_BALANCES_ACCOUNT if $has_initial_balance;
+
+ $ledger->add_accounts(@accounts);
+ }
+
+ if ($opts->{payees}) {
+ my $payees = $homebank->payees;
+ my @payees = map { $_->{name} } @$payees;
+
+ $ledger->add_payees(@payees);
+ }
+
+ if ($opts->{tags}) {
+ my $tags = $homebank->tags;
+
+ $ledger->add_tags(@$tags);
+ }
+
+ my %commodities;
+
+ for my $currency (@{$homebank->currencies}) {
+ my $commodity = {
+ symbol => $currency->{symbol},
+ format => $homebank->format_amount(1_000, $currency),
+ iso => $currency->{iso},
+ name => $currency->{name},
+ };
+ $commodities{$currency->{key}} = {
+ %$commodity,
+ syprf => $currency->{syprf},
+ dchar => $currency->{dchar},
+ gchar => $currency->{gchar},
+ frac => $currency->{frac},
+ };
+
+ $ledger->add_commodities($commodity) if $opts->{commodities};
+ }
+
+ if ($has_initial_balance) {
+ # transactions are sorted, so the first transaction is the oldest
+ my $first_date = $opts->{opening_date} || $transactions->[0]{date};
+ if ($first_date !~ /^\d{4}-\d{2}-\d{2}$/) {
+ die "Opening date must be in the form YYYY-MM-DD.\n";
+ }
+
+ my @postings;
+
+ for my $account (@$accounts) {
+ next if !$account->{initial} || $account->{excluded};
+
+ push @postings, {
+ account => $account->{ledger_name},
+ amount => $account->{initial},
+ commodity => $commodities{$account->{currency}},
+ };
+ }
+
+ push @postings, {
+ account => $OPENING_BALANCES_ACCOUNT,
+ };
+
+ $ledger->add_transactions({
+ date => $first_date,
+ payee => 'Opening Balance',
+ status => 'cleared',
+ postings => \@postings,
+ });
+ }
+
+ my %seen;
+
+ TRANSACTION:
+ for my $transaction (@$transactions) {
+ next if $seen{$transaction->{transfer_key} || ''};
+
+ my $account = $homebank->find_account_by_key($transaction->{account});
+ my $amount = $transaction->{amount};
+ my $status = $STATUS_SYMBOLS{$transaction->{status} || ''} || '';
+ my $paymode = $transaction->{paymode} || ''; # internaltransfer
+ my $memo = $transaction->{wording} || '';
+ my $payee = $homebank->find_payee_by_key($transaction->{payee});
+ my $tags = _split_tags($transaction->{tags});
+
+ my @postings;
+
+ push @postings, {
+ account => $account->{ledger_name},
+ amount => $amount,
+ commodity => $commodities{$account->{currency}},
+ payee => $payee->{name},
+ memo => $memo,
+ status => $status,
+ tags => $tags,
+ };
+
+ if ($paymode eq 'internaltransfer') {
+ my $paired_transaction = $homebank->find_transaction_transfer_pair($transaction);
+
+ my $dst_account = $homebank->find_account_by_key($transaction->{dst_account});
+ if (!$dst_account) {
+ if ($paired_transaction) {
+ $dst_account = $homebank->find_account_by_key($paired_transaction->{account});
+ }
+ if (!$dst_account) {
+ warn "Skipping internal transfer transaction with no destination account.\n";
+ next TRANSACTION;
+ }
+ }
+
+ $seen{$transaction->{transfer_key}}++ if $transaction->{transfer_key};
+ $seen{$paired_transaction->{transfer_key}}++ if $paired_transaction->{transfer_key};
+
+ my $paired_payee = $homebank->find_payee_by_key($paired_transaction->{payee});
+
+ push @postings, {
+ account => $dst_account->{ledger_name},
+ amount => $paired_transaction->{amount} || -$transaction->{amount},
+ commodity => $commodities{$dst_account->{currency}},
+ payee => $paired_payee->{name},
+ memo => $paired_transaction->{wording} || '',
+ status => $STATUS_SYMBOLS{$paired_transaction->{status} || ''} || $status,
+ tags => _split_tags($paired_transaction->{tags}),
+ };
+ }
+ elsif ($transaction->{flags}{split}) {
+ my @amounts = split(/\|\|/, $transaction->{split_amount} || '');
+ my @memos = split(/\|\|/, $transaction->{split_memo} || '');
+ my @categories = split(/\|\|/, $transaction->{split_category} || '');
+
+ for (my $i = 0; $amounts[$i]; ++$i) {
+ my $amount = -$amounts[$i];
+ my $category = $homebank->find_category_by_key($categories[$i]);
+ my $memo = $memos[$i] || '';
+ my $other_account = $category ? $category->{ledger_name} : $opts->{default_account};
+
+ push @postings, {
+ account => $other_account,
+ commodity => $commodities{$account->{currency}},
+ amount => $amount,
+ payee => $payee->{name},
+ memo => $memo,
+ status => $status,
+ tags => $tags,
+ };
+ }
+ }
+ else { # with or without category
+ my $category = $homebank->find_category_by_key($transaction->{category});
+ my $other_account = $category ? $category->{ledger_name} : $opts->{default_account};
+ push @postings, {
+ account => $other_account,
+ commodity => $commodities{$account->{currency}},
+ amount => -$transaction->{amount},
+ payee => $payee->{name},
+ memo => $memo,
+ status => $status,
+ tags => $tags,
+ };
+ }
+
+ # skip excluded accounts
+ for my $posting (@postings) {
+ for my $re (@{$opts->{exclude_accounts}}) {
+ next TRANSACTION if $posting->{account} =~ /$re/;
+ }
+ }
+
+ $ledger->add_transactions({
+ date => $transaction->{date},
+ payee => $payee->{name},
+ memo => $memo,
+ postings => \@postings,
+ });
+ }
+
+ return $ledger;
+}
+
+
+sub print_to_file {
+ my $self = shift;
+ my $str = shift;
+ my $filepath = shift;
+
+ my $out_fh = \*STDOUT;
+ if ($filepath) {
+ open($out_fh, '>', $filepath) or die "open failed: $!";
+ }
+ print $out_fh $str;
+}
+
+
+sub parse_args {
+ my $self = shift;
+ my @args = @_;
+
+ my %opts = (
+ version => 0,
+ help => 0,
+ manual => 0,
+ input => undef,
+ output => undef,
+ format => 'ledger',
+ account_width => 40,
+ accounts => 1,
+ payees => 1,
+ tags => 1,
+ commodities => 1,
+ opening_date => '',
+ default_account => 'Expenses:No Category',
+ rename_accounts => {},
+ exclude_accounts => [],
+ );
+
+ GetOptionsFromArray(\@args,
+ 'version|V' => \$opts{version},
+ 'help|h|?' => \$opts{help},
+ 'manual|man' => \$opts{manual},
+ 'input|file|i=s' => \$opts{input},
+ 'output|o=s' => \$opts{output},
+ 'format|f=s' => \$opts{format},
+ 'account-width=i' => \$opts{account_width},
+ 'accounts!' => \$opts{accounts},
+ 'payees!' => \$opts{payees},
+ 'tags!' => \$opts{tags},
+ 'commodities!' => \$opts{commodities},
+ 'opening-date=s' => \$opts{opening_date},
+ 'default-account=s' => \$opts{default_account},
+ 'rename-account|r=s' => \%{$opts{rename_accounts}},
+ 'exclude-account|x=s' => \@{$opts{exclude_accounts}},
+ ) or pod2usage(-exitval => 1, -verbose => 99, -sections => [qw(SYNOPSIS OPTIONS)]);
+
+ $opts{input} = shift @args if !$opts{input};
+ if (!$opts{input}) {
+ print STDERR "Input file is required.\n";
+ exit(1);
+ }
+
+ return \%opts;
+}
+
+sub _split_tags {
+ my $tags = shift;
+ return [split(/\h+/, $tags || '')];
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::HomeBank2Ledger - A tool to convert HomeBank files to Ledger format
+
+=head1 VERSION
+
+version 0.001
+
+=head1 SYNOPSIS
+
+ App::HomeBank2Ledger->main(@args);
+
+=head1 DESCRIPTION
+
+This module is part of the L<homebank2ledger> script.
+
+=head1 METHODS
+
+=head2 main
+
+ App::HomeBank2Ledger->main(@args);
+
+Run the script and exit; does not return.
+
+=head2 formatter
+
+ $formatter = $app->formatter($homebank, $opts);
+
+Generate a L<App::HomeBank2Ledger::Formatter>.
+
+=head2 convert_homebank_to_ledger
+
+ my $ledger = $app->convert_homebank_to_ledger($homebank, $opts);
+
+Converts a L<File::HomeBank> to a L<App::HomeBank2Ledger::Ledger>.
+
+=head2 print_to_file
+
+ $app->print_to_file($str);
+ $app->print_to_file($str, $filepath);
+
+Print a string to a file (or STDOUT).
+
+=head2 parse_args
+
+ $opts = $app->parse_args(@args);
+
+Parse command-line arguments.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::HomeBank2Ledger::Formatter;
+# ABSTRACT: Abstract class for formatting a ledger
+
+
+use warnings;
+use strict;
+
+use Module::Load;
+use Module::Pluggable search_path => [__PACKAGE__],
+ sub_name => 'available_formatters';
+
+our $VERSION = '0.001'; # VERSION
+
+sub _croak { require Carp; Carp::croak(@_) }
+
+
+sub new {
+ my $class = shift;
+ my %args = @_;
+
+ my $package = __PACKAGE__;
+
+ if ($class eq $package and my $type = $args{type}) {
+ # factory
+ for my $formatter ($class->available_formatters) {
+ next if lc($formatter) ne lc("${package}::${type}");
+ $class = $formatter;
+ load $class;
+ last;
+ }
+ _croak('Invalid formatter type') if $class eq $package;
+ }
+
+ return bless {%args}, $class;
+}
+
+
+sub format {
+ ...
+}
+
+
+sub type { shift->{type} }
+sub name { shift->{name} }
+sub file { shift->{file} }
+sub account_width { shift->{account_width} || 40 }
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::HomeBank2Ledger::Formatter - Abstract class for formatting a ledger
+
+=head1 VERSION
+
+version 0.001
+
+=head1 SYNOPSIS
+
+ my $formatter = App::HomeBank2Ledger::Formatter->new(
+ type => 'ledger',
+ );
+ print $formatter->format($ledger);
+
+=head1 DESCRIPTION
+
+This class formats L<ledger data|App::HomeBank2Ledger::Ledger> as for a file.
+
+=head1 ATTRIBUTES
+
+=head2 type
+
+Get the type of formatter.
+
+=head2 name
+
+Get the name or title of the ledger.
+
+=head2 file
+
+Get the filepath where the ledger data came from.
+
+=head2 account_width
+
+Get the number of characters to use for the account column.
+
+=head1 METHODS
+
+=head2 new
+
+ $formatter = App::HomeBank2Ledger::Formatter->new(type => $format);
+
+Construct a new formatter object.
+
+=head2 format
+
+ $str = $formatter->format($ledger);
+
+Do the actual formatting of ledger data into a serialized form.
+
+This must be overridden by subclasses.
+
+=head1 SEE ALSO
+
+=over 4
+
+=item *
+
+L<App::HomeBank2Ledger::Formatter::Beancount>
+
+=item *
+
+L<App::HomeBank2Ledger::Formatter::Ledger>
+
+=back
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::HomeBank2Ledger::Formatter::Beancount;
+# ABSTRACT: Beancount formatter
+
+
+use warnings;
+use strict;
+
+use App::HomeBank2Ledger::Util qw(commify rtrim);
+
+use parent 'App::HomeBank2Ledger::Formatter';
+
+our $VERSION = '0.001'; # VERSION
+
+my %STATUS_SYMBOLS = (
+ cleared => '*',
+ pending => '!',
+);
+my $UNKNOWN_DATE = '0001-01-01';
+
+sub _croak { require Carp; Carp::croak(@_) }
+
+sub format {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out = (
+ $self->_format_header,
+ $self->_format_accounts($ledger),
+ $self->_format_commodities($ledger),
+ # $self->_format_payees,
+ # $self->_format_tags,
+ $self->_format_transactions($ledger),
+ );
+
+ return join($/, map { rtrim($_) } @out);
+}
+
+sub _format_header {
+ my $self = shift;
+
+ my @out;
+
+ if (my $name = $self->name) {
+ push @out, "; Name: $name";
+ }
+
+ my $file = $self->file;
+ push @out, "; Converted from ${file} using homebank2ledger ${VERSION}";
+
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_accounts {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ for my $account (sort @{$ledger->accounts}) {
+ my $oldest_transaction = $self->_find_oldest_transaction_by_account($account, $ledger);
+ my $account_date = $oldest_transaction->{date} || $UNKNOWN_DATE;
+ $account = $self->_format_account($account);
+
+ push @out, "${account_date} open ${account}";
+ }
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_commodities {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ for my $commodity (@{$ledger->commodities}) {
+ my $oldest_transaction = $self->_find_oldest_transaction_by_commodity($commodity, $ledger);
+ my $commodity_date = $oldest_transaction->{date} || $UNKNOWN_DATE;
+
+ push @out, "${commodity_date} commodity $commodity->{iso}";
+ push @out, ' name: '.$self->_format_string($commodity->{name}) if $commodity->{name};
+ }
+
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_transactions {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ for my $transaction (@{$ledger->transactions}) {
+ push @out, $self->_format_transaction($transaction);
+ }
+
+ return @out;
+}
+
+sub _format_transaction {
+ my $self = shift;
+ my $transaction = shift;
+
+ my $account_width = $self->account_width;
+
+ my $date = $transaction->{date};
+ my $status = $transaction->{status};
+ my $payee = $transaction->{payee} || '';
+ my $memo = $transaction->{memo} || '';
+ my @postings = @{$transaction->{postings}};
+
+ my @out;
+
+ # figure out the Ledger transaction status
+ my $status_symbol = $STATUS_SYMBOLS{$status || ''};
+ if (!$status_symbol) {
+ my %posting_statuses = map { ($_->{status} || '') => 1 } @postings;
+ if (keys(%posting_statuses) == 1) {
+ my ($status) = keys %posting_statuses;
+ $status_symbol = $STATUS_SYMBOLS{$status || 'none'} || '';
+ }
+ }
+
+ push @out, sprintf('%s%s%s%s', $date,
+ $status_symbol && ' '.$status_symbol || ' *', # status (or "txn") is required
+ ($payee || $memo) && ' '.$self->_format_string($payee),
+ $memo && ' '.$self->_format_string($memo),
+ );
+
+ if (my %tags = map { $_ => 1 } map { @{$_->{tags} || []} } @postings) {
+ my @tags = map { "#$_" } keys %tags;
+ $out[-1] .= ' '.join(' ', @tags);
+ }
+
+ for my $posting (@postings) {
+ my @line;
+
+ my $posting_status_symbol = '';
+ if (!$status_symbol) {
+ $posting_status_symbol = $STATUS_SYMBOLS{$posting->{status} || ''} || '';
+ }
+
+ my $account = $self->_format_account($posting->{account});
+
+ push @line, ($posting_status_symbol ? " $posting_status_symbol " : ' ');
+ push @line, sprintf("\%-${account_width}s", $account);
+ push @line, ' ';
+ push @line, $self->_format_amount($posting->{amount}, $posting->{commodity}) if defined $posting->{amount};
+
+ push @out, join('', @line);
+ }
+
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_account {
+ my $self = shift;
+ my $account = shift;
+ $account =~ s/[^A-Za-z0-9:]+/-/g;
+ $account =~ s/-+/-/g;
+ $account =~ s/(?:^|(?<=:))([a-z])/uc($1)/eg;
+ return $account;
+}
+
+sub _format_string {
+ my $self = shift;
+ my $str = shift;
+ $str =~ s/"/\\"/g;
+ return "\"$str\"";
+}
+
+sub _format_amount {
+ my $self = shift;
+ my $amount = shift;
+ my $commodity = shift or _croak 'Must provide a valid currency';
+
+ my $format = "\% .$commodity->{frac}f";
+ my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
+
+ # beancount doesn't support different notations
+ my $num = join('.', commify($whole), $fraction);
+
+ $num = "$num $commodity->{iso}";
+
+ return $num;
+}
+
+sub _find_oldest_transaction_by_account {
+ my $self = shift;
+ my $account = shift;
+ my $ledger = shift;
+
+ $account = $self->_format_account($account);
+
+ my $oldest = $self->{oldest_transaction_by_account};
+ if (!$oldest) {
+ # build index
+ for my $transaction (@{$ledger->transactions}) {
+ for my $posting (@{$transaction->{postings}}) {
+ my $account = $self->_format_account($posting->{account});
+
+ if ($transaction->{date} lt ($oldest->{$account}{date} || '9999-99-99')) {
+ $oldest->{$account} = $transaction;
+ }
+ }
+ }
+
+ $self->{oldest_transaction_by_account} = $oldest;
+ }
+
+ return $oldest->{$account};
+}
+
+sub _find_oldest_transaction_by_commodity {
+ my $self = shift;
+ my $commodity = shift;
+ my $ledger = shift;
+
+ my $oldest = $self->{oldest_transaction_by_commodity};
+ if (!$oldest) {
+ # build index
+ for my $transaction (@{$ledger->transactions}) {
+ for my $posting (@{$transaction->{postings}}) {
+ my $symbol = $posting->{commodity}{symbol};
+ next if !$symbol;
+
+ if ($transaction->{date} lt ($oldest->{$symbol}{date} || '9999-99-99')) {
+ $oldest->{$symbol} = $transaction;
+ }
+ }
+ }
+
+ $self->{oldest_transaction_by_commodity} = $oldest;
+ }
+
+ return $oldest->{$commodity->{symbol}};
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::HomeBank2Ledger::Formatter::Beancount - Beancount formatter
+
+=head1 VERSION
+
+version 0.001
+
+=head1 DESCRIPTION
+
+This is a formatter for L<Beancount|http://furius.ca/beancount/>.
+
+=head1 SEE ALSO
+
+L<App::HomeBank2Ledger::Formatter>
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::HomeBank2Ledger::Formatter::Ledger;
+# ABSTRACT: Ledger formatter
+
+
+use warnings;
+use strict;
+
+use App::HomeBank2Ledger::Util qw(commify rtrim);
+
+use parent 'App::HomeBank2Ledger::Formatter';
+
+our $VERSION = '0.001'; # VERSION
+
+my %STATUS_SYMBOLS = (
+ cleared => '*',
+ pending => '!',
+);
+
+sub _croak { require Carp; Carp::croak(@_) }
+
+sub format {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out = (
+ $self->_format_header,
+ $self->_format_accounts($ledger),
+ $self->_format_commodities($ledger),
+ $self->_format_payees($ledger),
+ $self->_format_tags($ledger),
+ $self->_format_transactions($ledger),
+ );
+
+ return join($/, map { rtrim($_) } @out);
+}
+
+sub _format_header {
+ my $self = shift;
+
+ my @out;
+
+ if (my $name = $self->name) {
+ push @out, "; Name: $name";
+ }
+
+ my $file = $self->file;
+ push @out, "; Converted from ${file} using homebank2ledger ${VERSION}";
+
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_accounts {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ push @out, map { "account $_" } sort @{$ledger->accounts};
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_commodities {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ for my $commodity (@{$ledger->commodities}) {
+ push @out, "commodity $commodity->{symbol}";
+ push @out, " note $commodity->{name}" if $commodity->{name};
+ push @out, " format $commodity->{format}" if $commodity->{format};
+ push @out, " alias $commodity->{iso}" if $commodity->{iso};
+ }
+
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_payees {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ push @out, map { "payee $_" } sort @{$ledger->payees};
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_tags {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ push @out, map { "tag $_" } sort @{$ledger->tags};
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_transactions {
+ my $self = shift;
+ my $ledger = shift;
+
+ my @out;
+
+ for my $transaction (@{$ledger->transactions}) {
+ push @out, $self->_format_transaction($transaction);
+ }
+
+ return @out;
+}
+
+sub _format_transaction {
+ my $self = shift;
+ my $transaction = shift;
+
+ my $account_width = $self->account_width;
+
+ my $date = $transaction->{date};
+ my $status = $transaction->{status};
+ my $payee = $self->_format_string($transaction->{payee} || '');
+ my $memo = $self->_format_string($transaction->{memo} || '');
+ my @postings = @{$transaction->{postings}};
+
+ my @out;
+
+ # figure out the Ledger transaction status
+ my $status_symbol = $STATUS_SYMBOLS{$status || ''};
+ if (!$status_symbol) {
+ my %posting_statuses = map { ($_->{status} || '') => 1 } @postings;
+ if (keys(%posting_statuses) == 1) {
+ my ($status) = keys %posting_statuses;
+ $status_symbol = $STATUS_SYMBOLS{$status || 'none'} || '';
+ }
+ }
+
+ $payee =~ s/(?: )|\t;/ ;/g; # don't turn into a memo
+
+ push @out, sprintf('%s%s%s%s', $date,
+ $status_symbol && " ${status_symbol}",
+ $payee && " $payee",
+ $memo && " ; $memo",
+ );
+
+ for my $posting (@postings) {
+ my @line;
+
+ my $posting_status_symbol = '';
+ if (!$status_symbol) {
+ $posting_status_symbol = $STATUS_SYMBOLS{$posting->{status} || ''} || '';
+ }
+
+ push @line, ($posting_status_symbol ? " $posting_status_symbol " : ' ');
+ push @line, sprintf("\%-${account_width}s", $posting->{account});
+ push @line, ' ';
+ push @line, $self->_format_amount($posting->{amount}, $posting->{commodity}) if defined $posting->{amount};
+
+ push @out, join('', @line);
+
+ if (my $payee = $posting->{payee}) {
+ push @out, ' ; Payee: '.$self->_format_string($payee);
+ }
+
+ if (my @tags = @{$posting->{tags} || []}) {
+ push @out, " ; :".join(':', @tags).":";
+ }
+ }
+
+ push @out, '';
+
+ return @out;
+}
+
+sub _format_string {
+ my $self = shift;
+ my $str = shift;
+ $str =~ s/\v//g;
+ return $str;
+}
+
+sub _format_amount {
+ my $self = shift;
+ my $amount = shift;
+ my $commodity = shift or _croak 'Must provide a valid currency';
+
+ my $format = "\% .$commodity->{frac}f";
+ my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
+
+ my $num = join($commodity->{dchar}, commify($whole, $commodity->{gchar}), $fraction);
+
+ $num = $commodity->{syprf} ? "$commodity->{symbol} $num" : "$num $commodity->{symbol}";
+
+ return $num;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::HomeBank2Ledger::Formatter::Ledger - Ledger formatter
+
+=head1 VERSION
+
+version 0.001
+
+=head1 DESCRIPTION
+
+This is a formatter for L<Ledger|https://www.ledger-cli.org/>.
+
+=head1 SEE ALSO
+
+L<App::HomeBank2Ledger::Formatter>
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::HomeBank2Ledger::Ledger;
+# ABSTRACT: Ledger data representation
+
+
+use warnings;
+use strict;
+
+our $VERSION = '0.001'; # VERSION
+
+
+sub new {
+ my $class = shift;
+ my %args = @_;
+ return bless {%args}, $class;
+}
+
+
+sub accounts { shift->{accounts} || [] }
+sub commodities { shift->{commodities} || [] }
+sub payees { shift->{payees} || [] }
+sub tags { shift->{tags} || [] }
+sub transactions { shift->{transactions} || [] }
+
+
+# TODO - These should validate incoming data.
+
+sub add_accounts {
+ my $self = shift;
+ push @{$self->{accounts}}, @_;
+}
+
+sub add_commodities {
+ my $self = shift;
+ push @{$self->{commodities}}, @_;
+}
+
+sub add_payees {
+ my $self = shift;
+ push @{$self->{payees}}, @_;
+}
+
+sub add_tags {
+ my $self = shift;
+ push @{$self->{tags}}, @_;
+}
+
+sub add_transactions {
+ my $self = shift;
+ push @{$self->{transactions}}, @_;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::HomeBank2Ledger::Ledger - Ledger data representation
+
+=head1 VERSION
+
+version 0.001
+
+=head1 SYNOPSIS
+
+ my $ledger = App::HomeBank2Ledger::Ledger->new;
+
+ $ledger->add_payees("Ann's Antiques", "Missy Automative");
+
+ for my $payee (@{$ledger->payees}) {
+ print "Payee: $payee\n";
+ }
+
+=head1 DESCRIPTION
+
+This class provides a unified in-memory representation of a ledger, including associated metadata.
+
+Here is a specification for the substructures:
+
+=head2 account
+
+This is a fully-qualified account name. Names may contain colons for representing a hierarchy of
+accounts. Examples:
+
+=head1 ATTRIBUTES
+
+=head2 accounts
+
+Get an arrayref of accounts.
+
+=head2 commodities
+
+Get an arrayref of commodities.
+
+=head2 payees
+
+Get an arrayref of payees.
+
+=head2 tags
+
+Get an arrayref of tags.
+
+=head2 transactions
+
+Get an arrayref of transactions.
+
+=head1 METHODS
+
+=head2 new
+
+ $ledger = App::HomeBank2Ledger::Ledger->new(%ledger_data);
+
+Construct a new ledger instance.
+
+=head2 add_accounts
+
+Add accounts.
+
+=head2 add_commodities
+
+Add commodities.
+
+=head2 add_payees
+
+Add payees.
+
+=head2 add_tags
+
+Add tags.
+
+=head2 add_transactions
+
+Add transactions.
+
+=for: list
+* "Assets:Bank:Chase1234"
+* "Liabilities:Credit Card:CapitalOne"
+
+=head2 commodity
+
+This is a hashref like this:
+
+ {
+ symbol => '$', # required
+ iso => 'USD', # optional
+ name => 'US Dollar', # optional
+ format => '$1000.00', # optional
+ }
+
+=head2 payee
+
+This is just a string with the name of a "payee" or memo/description/narration.
+
+=head2 tag
+
+This is just a string with the text of a tag.
+
+=head2 transaction
+
+This is a hashref like this:
+
+ {
+ date => '2019-06-12', # required
+ payee => 'Malcolm Reynolds', # required
+ status => 'cleared', # optional; can be "cleared" or "pending"
+ memo => 'Medical supplies', # optional
+ postings => [ # required
+ {
+ account => 'Some Account', # required
+ amount => '16.25', # required for at least n-1 postings
+ commodity => {
+ symbol => '$',
+ format => '$1,000.00',
+ iso => 'USD',
+ name => 'US Dollar',
+ syprf => 1,
+ dchar => '.',
+ gchar => ',',
+ frac => 2,
+ },
+ payee => 'Somebody', # optional
+ memo => 'Whatever', # optional
+ status => 'pending', # optional; can be "cleared" or "pending"
+ tags => [qw(niska train-job)],
+ },
+ ...
+ ],
+ }
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package App::HomeBank2Ledger::Util;
+# ABSTRACT: Miscellaneous utility functions
+
+use warnings;
+use strict;
+
+use Exporter qw(import);
+
+our $VERSION = '0.001'; # VERSION
+
+our @EXPORT_OK = qw(commify rtrim);
+
+
+sub commify {
+ my $num = shift;
+ my $comma = shift || ',';
+
+ my $str = reverse $num;
+ $str =~ s/(\d\d\d)(?=\d)(?!\d*\.)/$1$comma/g;
+
+ return scalar reverse $str;
+}
+
+
+sub rtrim {
+ my $str = shift;
+ $str =~ s/\h+$//;
+ return $str;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+App::HomeBank2Ledger::Util - Miscellaneous utility functions
+
+=head1 VERSION
+
+version 0.001
+
+=head1 FUNCTIONS
+
+=head2 commify
+
+ $commified = commify($num);
+ $commified = commify($num, $comma_char);
+
+Just another commify subroutine.
+
+=head2 rtrim
+
+ $trimmed_str = rtrim($str);
+
+Right-trim a string.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+package File::HomeBank;
+# ABSTRACT: Parse HomeBank files
+
+
+use warnings;
+use strict;
+
+use App::HomeBank2Ledger::Util qw(commify);
+use Exporter qw(import);
+use Scalar::Util qw(refaddr);
+use Time::Piece;
+use XML::Entities;
+use XML::Parser::Lite;
+
+our $VERSION = '0.001'; # VERSION
+
+our @EXPORT_OK = qw(parse_string parse_file);
+
+my %ACCOUNT_TYPES = (
+ 0 => 'none',
+ 1 => 'bank',
+ 2 => 'cash',
+ 3 => 'asset',
+ 4 => 'creditcard',
+ 5 => 'liability',
+ 6 => 'stock',
+ 7 => 'mutualfund',
+ 8 => 'income',
+ 9 => 'expense',
+ 10 => 'equity',
+);
+my %ACCOUNT_FLAGS = (
+ 0 => 'oldbudget',
+ 1 => 'closed',
+ 2 => 'added',
+ 3 => 'changed',
+ 4 => 'nosummary',
+ 5 => 'nobudget',
+ 6 => 'noreport',
+);
+my %CURRENCY_FLAGS = (
+ 1 => 'custom',
+);
+my %CATEGORY_FLAGS = (
+ 0 => 'sub',
+ 1 => 'income',
+ 2 => 'custom',
+ 3 => 'budget',
+ 4 => 'forced',
+);
+my %TRANSACTION_FLAGS = (
+ 0 => 'oldvalid',
+ 1 => 'income',
+ 2 => 'auto',
+ 3 => 'added',
+ 4 => 'changed',
+ 5 => 'oldremind',
+ 6 => 'cheq2',
+ 7 => 'limit',
+ 8 => 'split',
+);
+my %TRANSACTION_STATUSES = (
+ 0 => 'none',
+ 1 => 'cleared',
+ 2 => 'reconciled',
+ 3 => 'remind',
+ 4 => 'void',
+);
+my %TRANSACTION_PAYMODES = (
+ 0 => 'none',
+ 1 => 'creditcard',
+ 2 => 'check',
+ 3 => 'cash',
+ 4 => 'transfer',
+ 5 => 'internaltransfer',
+ 6 => 'debitcard',
+ 7 => 'repeatpayment',
+ 8 => 'epayment',
+ 9 => 'deposit',
+ 10 => 'fee',
+ 11 => 'directdebit',
+);
+
+sub _croak { require Carp; Carp::croak(@_) }
+sub _usage { _croak("Usage: @_\n") }
+
+
+my %CACHE;
+
+sub new {
+ my $class = shift;
+ my %args = @_;
+
+ my $self;
+
+ if (my $filepath = $args{file}) {
+ $self = parse_file($filepath);
+ $self->{file} = $filepath;
+ }
+ elsif (my $str = $args{string}) {
+ $self = parse_string($str);
+ }
+ else {
+ _usage(q{File::HomeBank->new(string => $str)});
+ }
+
+ return bless $self, $class;
+}
+
+sub DESTROY {
+ my $self = shift;
+ my $in_global_destruction = shift;
+ delete $CACHE{refaddr($self)} if !$in_global_destruction;
+}
+
+
+sub file {
+ shift->{file};
+}
+
+
+sub title {
+ shift->{properties}{title};
+}
+
+
+sub base_currency {
+ shift->{properties}{currency};
+}
+
+
+sub accounts { shift->{accounts} || [] }
+sub categories { shift->{categories} || [] }
+sub currencies { shift->{currencies} || [] }
+sub payees { shift->{payees} || [] }
+sub transactions { shift->{transactions} || [] }
+
+sub tags {
+ my $self = shift;
+
+ my %tags;
+
+ for my $transaction (@{$self->transactions}) {
+ for my $tag (split(/\h+/, $transaction->{tags} || '')) {
+ $tags{$tag} = 1;
+ }
+ }
+
+ return [keys %tags];
+}
+
+
+sub find_account_by_key {
+ my $self = shift;
+ my $key = shift or return;
+
+ my $index = $CACHE{refaddr($self)}{account_by_key};
+ if (!$index) {
+ # build index
+ for my $account (@{$self->accounts}) {
+ $index->{$account->{key}} = $account;
+ }
+
+ $CACHE{refaddr($self)}{account_by_key} = $index;
+ }
+
+ return $index->{$key};
+}
+
+
+sub find_currency_by_key {
+ my $self = shift;
+ my $key = shift or return;
+
+ my $index = $CACHE{refaddr($self)}{currency_by_key};
+ if (!$index) {
+ # build index
+ for my $currency (@{$self->currencies}) {
+ $index->{$currency->{key}} = $currency;
+ }
+
+ $CACHE{refaddr($self)}{currency_by_key} = $index;
+ }
+
+ return $index->{$key};
+}
+
+
+sub find_category_by_key {
+ my $self = shift;
+ my $key = shift or return;
+
+ my $index = $CACHE{refaddr($self)}{category_by_key};
+ if (!$index) {
+ # build index
+ for my $category (@{$self->categories}) {
+ $index->{$category->{key}} = $category;
+ }
+
+ $CACHE{refaddr($self)}{category_by_key} = $index;
+ }
+
+ return $index->{$key};
+}
+
+
+sub find_payee_by_key {
+ my $self = shift;
+ my $key = shift or return;
+
+ my $index = $CACHE{refaddr($self)}{payee_by_key};
+ if (!$index) {
+ # build index
+ for my $payee (@{$self->payees}) {
+ $index->{$payee->{key}} = $payee;
+ }
+
+ $CACHE{refaddr($self)}{payee_by_key} = $index;
+ }
+
+ return $index->{$key};
+}
+
+
+sub find_transactions_by_transfer_key {
+ my $self = shift;
+ my $key = shift or return;
+
+ my $index = $CACHE{refaddr($self)}{transactions_by_transfer_key};
+ if (!$index) {
+ # build index
+ for my $transaction (@{$self->transactions}) {
+ my $xfkey = $transaction->{transfer_key} or next;
+ push @{$index->{$xfkey} ||= []}, $transaction;
+ }
+
+ $CACHE{refaddr($self)}{transactions_by_transfer_key} = $index;
+ }
+
+ return @{$index->{$key} || []};
+}
+
+
+sub find_transaction_transfer_pair {
+ my $self = shift;
+ my $transaction = shift;
+
+ return if $transaction->{paymode} ne 'internaltransfer';
+
+ my $transfer_key = $transaction->{transfer_key};
+
+ my @matching = grep { refaddr($_) != refaddr($transaction) }
+ $self->find_transactions_by_transfer_key($transfer_key);
+ warn "Found more than two transactions with the same transfer key.\n" if 1 < @matching;
+ return $matching[0] if @matching;
+
+ warn "Found internal transfer with no tranfer key.\n" if !defined $transfer_key;
+
+ my $dst_account = $self->find_account_by_key($transaction->{dst_account});
+ if (!$dst_account) {
+ warn "Found internal transfer with no destination account.\n";
+ return;
+ }
+
+ my @candidates;
+
+ for my $t (@{$self->transactions}) {
+ next if $t->{paymode} ne 'internaltransfer';
+ next if $t->{account} != $transaction->{dst_account};
+ next if $t->{dst_account} != $transaction->{account};
+ next if $t->{amount} != -$transaction->{amount};
+
+ my @matching = $self->find_transactions_by_transfer_key($t->{transfer_key});
+ next if 1 < @matching; # other transaction must also be orphaned
+
+ push @candidates, $t;
+ }
+
+ my $transaction_day = _ymd_to_julian($transaction->{date});
+
+ # sort the candidates so we can pick the nearest one by date
+ my @ordered_candidates =
+ map { $_->[1] }
+ sort { $a->[0] <=> $b->[0] }
+ map { [abs($transaction_day - _ymd_to_julian($_->{date})), $_] } @candidates;
+
+ if (my $winner = $ordered_candidates[0]) {
+ my $key1 = $transfer_key || '[no key]';
+ my $key2 = $winner->{transfer_key} || '[no key]';
+ warn "Paired orphaned internal transfer ${key1} and ${key2}.\n";
+ return $winner;
+ }
+}
+
+
+sub sorted_transactions {
+ my $self = shift;
+
+ my $sorted_transactions = $CACHE{refaddr($self)}{sorted_transactions};
+ if (!$sorted_transactions) {
+ $sorted_transactions = [sort { $a->{date} cmp $b->{date} } @{$self->transactions}];
+
+ $CACHE{refaddr($self)}{sorted_transactions} = $sorted_transactions;
+ }
+
+ return $sorted_transactions;
+}
+
+
+sub full_category_name {
+ my $self = shift;
+ my $key = shift or return;
+
+ my $cat = $self->find_category_by_key($key);
+
+ my @categories = ($cat);
+
+ while (my $parent_key = $cat->{parent}) {
+ $cat = $self->find_category_by_key($parent_key);
+ unshift @categories, $cat;
+ }
+
+ return join(':', map { $_->{name} } @categories);
+}
+
+
+sub format_amount {
+ my $self = shift;
+ my $amount = shift;
+ my $currency = shift || $self->base_currency;
+
+ $currency = $self->find_currency_by_key($currency) if !ref($currency);
+ _croak 'Must provide a valid currency' if !$currency;
+
+ my $format = "\% .$currency->{frac}f";
+ my ($whole, $fraction) = split(/\./, sprintf($format, $amount));
+
+ my $num = join($currency->{dchar}, commify($whole, $currency->{gchar}), $fraction);
+
+ $num = $currency->{syprf} ? "$currency->{symbol} $num" : "$num $currency->{symbol}";
+
+ return $num;
+}
+
+
+sub parse_file {
+ my $filepath = shift or _usage(q{parse_file($filepath)});
+
+ open(my $fh, '<', $filepath) or die "open failed: $!";
+ my $str_in = do { local $/; <$fh> };
+
+ return parse_string($str_in);
+}
+
+
+sub parse_string {
+ my $str = shift or die _usage(q{parse_string($str)});
+
+ my %properties;
+ my @accounts;
+ my @payees;
+ my @categories;
+ my @currencies;
+ my @transactions;
+
+ my $xml_parser = XML::Parser::Lite->new(
+ Handlers => {
+ Start => sub {
+ shift;
+ my $node = shift;
+ my %attr = @_;
+
+ # decode all attribute values
+ for my $key (keys %attr) {
+ $attr{$key} = _decode_xml_entities($attr{$key});
+ }
+
+ if ($node eq 'properties') {
+ $attr{currency} = delete $attr{curr} if $attr{curr};
+ %properties = %attr;
+ }
+ elsif ($node eq 'account') {
+ $attr{type} = $ACCOUNT_TYPES{$attr{type} || ''} || 'unknown';
+ $attr{bank_name} = delete $attr{bankname} if $attr{bankname};
+ $attr{currency} = delete $attr{curr} if $attr{curr};
+ $attr{display_position} = delete $attr{pos} if $attr{pos};
+
+ my $flags = delete $attr{flags} || 0;
+ while (my ($shift, $name) = each %ACCOUNT_FLAGS) {
+ $attr{flags}{$name} = $flags & (1 << $shift) ? 1 : 0;
+ }
+
+ push @accounts, \%attr;
+ }
+ elsif ($node eq 'pay') { # payee
+ push @payees, \%attr;
+ }
+ elsif ($node eq 'cur') { # currency
+ $attr{symbol} = delete $attr{symb} if $attr{symb};
+
+ my $flags = delete $attr{flags} || 0;
+ while (my ($shift, $name) = each %CURRENCY_FLAGS) {
+ $attr{flags}{$name} = $flags & (1 << $shift) ? 1 : 0;
+ }
+
+ push @currencies, \%attr;
+ }
+ elsif ($node eq 'cat') { # category
+ my $flags = delete $attr{flags} || 0;
+ while (my ($shift, $name) = each %CATEGORY_FLAGS) {
+ $attr{flags}{$name} = $flags & (1 << $shift) ? 1 : 0;
+ }
+
+ push @categories, \%attr;
+ }
+ elsif ($node eq 'ope') { # transaction
+ $attr{paymode} = $TRANSACTION_PAYMODES{$attr{paymode} || ''} || 'unknown';
+ $attr{status} = $TRANSACTION_STATUSES{delete $attr{st}} || 'unknown';
+
+ $attr{transfer_key} = delete $attr{kxfer} if $attr{kxfer};
+ $attr{split_amount} = delete $attr{samt} if $attr{samt};
+ $attr{split_memo} = delete $attr{smem} if $attr{smem};
+ $attr{split_category} = delete $attr{scat} if $attr{scat};
+
+ $attr{date} = _rdn_to_ymd($attr{date}) if $attr{date};
+
+ my $flags = delete $attr{flags} || 0;
+ while (my ($shift, $name) = each %TRANSACTION_FLAGS) {
+ $attr{flags}{$name} = $flags & (1 << $shift) ? 1 : 0;
+ }
+
+ push @transactions, \%attr;
+ }
+ },
+ },
+ );
+ $xml_parser->parse($str);
+
+ return {
+ properties => \%properties,
+ accounts => \@accounts,
+ payees => \@payees,
+ categories => \@categories,
+ currencies => \@currencies,
+ transactions => \@transactions,
+ };
+}
+
+sub _decode_xml_entities {
+ my $str = shift;
+ # decoding entities can be extremely slow, so don't bother if it doesn't look like there are any
+ # entities to decode
+ return $str if $str !~ /&(?:#\d+)|[A-Za-z0-9]+;/;
+ return XML::Entities::decode('all', $str);
+}
+
+sub _rdn_to_unix_epoch {
+ my $rdn = shift;
+ my $jan01_1970 = 719163;
+ return ($rdn - $jan01_1970) * 86400;
+}
+
+sub _rdn_to_ymd {
+ my $rdn = shift;
+ my $epoch = _rdn_to_unix_epoch($rdn);
+ my $time = gmtime($epoch);
+ return $time->ymd;
+};
+
+sub _ymd_to_julian {
+ my $ymd = shift;
+ my $t = Time::Piece->strptime($ymd, '%Y-%m-%d');
+ return $t->julian_day;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+File::HomeBank - Parse HomeBank files
+
+=head1 VERSION
+
+version 0.001
+
+=head1 SYNOPSIS
+
+ # Functional:
+
+ use File::HomeBank qw(parse_file);
+
+ my $raw_data = parse_file('path/to/homebank.xhb');
+
+ # Or OOP:
+
+ my $homebank = File::HomeBank->new(file => 'path/to/homebank.xhb');
+
+ for my $account (@{$homebank->accounts}) {
+ print "Found account named $account->{name}\n";
+ }
+
+=head1 DESCRIPTION
+
+This module parses L<HomeBank|http://homebank.free.fr/> files.
+
+=head1 ATTRIBUTES
+
+=head2 file
+
+Get the filepath (if parsed from a file).
+
+=head1 METHODS
+
+=head2 new
+
+ $homebank = File::HomeBank->new(string => $str);
+ $homebank = File::HomeBank->new(file => $filepath);
+
+Construct a L<File::HomeBank>.
+
+=head2 title
+
+ $title = $homebank->title;
+
+Get the title or owner property.
+
+=head2 base_currency
+
+ $base_currency = $homebank->base_currency;
+
+Get the key of the base currency.
+
+=head2 accounts
+
+Get an arrayref of accounts.
+
+=head2 categories
+
+Get an arrayref of categories.
+
+=head2 currencies
+
+Get an arrayref of currencies.
+
+=head2 payees
+
+Get an arrayref of payees.
+
+=head2 tags
+
+Get an arrayref of tags.
+
+=head2 transactions
+
+Get an arrayref of transactions.
+
+=head2 find_account_by_key
+
+ $account = $homebank->find_account_by_key($key);
+
+Find a account with the given key.
+
+=head2 find_currency_by_key
+
+ $currency = $homebank->find_currency_by_key($key);
+
+Find a currency with the given key.
+
+=head2 find_category_by_key
+
+ $category = $homebank->find_category_by_key($key);
+
+Find a category with the given key.
+
+=head2 find_payee_by_key
+
+ $payee = $homebank->find_payee_by_key($key);
+
+Find a payee with the given key.
+
+=head2 find_transactions_by_transfer_key
+
+ @transactions = $homebank->find_transactions_by_transfer_key($key);
+
+Find all transactions that share the same transfer key.
+
+=head2 find_transaction_transfer_pair
+
+ $other_transaction = $homebank->find_transaction_transfer_pair($transaction);
+
+Given a transaction hashref, return its corresponding transaction if it is an internal transfer. If
+the transaction is an internal transaction with a destination account but is orphaned (has no
+matching transfer key), this also looks for another orphaned transaction in the destination account
+that it can call its partner.
+
+Returns undef or empty if no corresponding transaction is found.
+
+=head2 sorted_transactions
+
+ $transations = $homebank->sorted_transactions;
+
+Get an arrayref of transactions sorted by date (oldest first).
+
+=head2 full_category_name
+
+ $category_name = $homebank->full_category_name($key);
+
+Generate the full name for a category, taking category inheritance into consideration.
+
+ Income
+ Salary <--
+
+will become:
+
+ "Income:Salary"
+
+=head2 format_amount
+
+ $formatted_amount = $homebank->format_amount($amount);
+ $formatted_amount = $homebank->format_amount($amount, $currency);
+
+Formats an amount in either the base currency (for the whole file) or in the given currency.
+Currency can be a key or the actualy currency structure.
+
+=head1 FUNCTIONS
+
+=head2 parse_file
+
+ $homebank_data = parse_file($filepath);
+
+Read and parse a HomeBank .xhb file from a filesystem.
+
+=head2 parse_string
+
+ $homebank_data = parse_string($str);
+
+Parse a HomeBank file from a string.
+
+=head1 BUGS
+
+Please report any bugs or feature requests on the bugtracker website
+L<https://github.com/chazmcgarvey/homebank2ledger/issues>
+
+When submitting a bug or request, please include a test-file or a
+patch to an existing test-file that illustrates the bug or desired
+feature.
+
+=head1 AUTHOR
+
+Charles McGarvey <chazmcgarvey@brokenzipper.com>
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is Copyright (c) 2019 by Charles McGarvey.
+
+This is free software, licensed under:
+
+ The MIT (X11) License
+
+=cut
--- /dev/null
+use 5.006;
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::Compile 2.058
+
+use Test::More;
+
+plan tests => 8 + ($ENV{AUTHOR_TESTING} ? 1 : 0);
+
+my @module_files = (
+ 'App/HomeBank2Ledger.pm',
+ 'App/HomeBank2Ledger/Formatter.pm',
+ 'App/HomeBank2Ledger/Formatter/Beancount.pm',
+ 'App/HomeBank2Ledger/Formatter/Ledger.pm',
+ 'App/HomeBank2Ledger/Ledger.pm',
+ 'App/HomeBank2Ledger/Util.pm',
+ 'File/HomeBank.pm'
+);
+
+my @scripts = (
+ 'bin/homebank2ledger'
+);
+
+# no fake home requested
+
+my @switches = (
+ -d 'blib' ? '-Mblib' : '-Ilib',
+);
+
+use File::Spec;
+use IPC::Open3;
+use IO::Handle;
+
+open my $stdin, '<', File::Spec->devnull or die "can't open devnull: $!";
+
+my @warnings;
+for my $lib (@module_files)
+{
+ # see L<perlfaq8/How can I capture STDERR from an external command?>
+ my $stderr = IO::Handle->new;
+
+ diag('Running: ', join(', ', map { my $str = $_; $str =~ s/'/\\'/g; q{'} . $str . q{'} }
+ $^X, @switches, '-e', "require q[$lib]"))
+ if $ENV{PERL_COMPILE_TEST_DEBUG};
+
+ my $pid = open3($stdin, '>&STDERR', $stderr, $^X, @switches, '-e', "require q[$lib]");
+ binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+ my @_warnings = <$stderr>;
+ waitpid($pid, 0);
+ is($?, 0, "$lib loaded ok");
+
+ shift @_warnings if @_warnings and $_warnings[0] =~ /^Using .*\bblib/
+ and not eval { +require blib; blib->VERSION('1.01') };
+
+ if (@_warnings)
+ {
+ warn @_warnings;
+ push @warnings, @_warnings;
+ }
+}
+
+foreach my $file (@scripts)
+{ SKIP: {
+ open my $fh, '<', $file or warn("Unable to open $file: $!"), next;
+ my $line = <$fh>;
+
+ close $fh and skip("$file isn't perl", 1) unless $line =~ /^#!\s*(?:\S*perl\S*)((?:\s+-\w*)*)(?:\s*#.*)?$/;
+ @switches = (@switches, split(' ', $1)) if $1;
+
+ close $fh and skip("$file uses -T; not testable with PERL5LIB", 1)
+ if grep { $_ eq '-T' } @switches and $ENV{PERL5LIB};
+
+ my $stderr = IO::Handle->new;
+
+ diag('Running: ', join(', ', map { my $str = $_; $str =~ s/'/\\'/g; q{'} . $str . q{'} }
+ $^X, @switches, '-c', $file))
+ if $ENV{PERL_COMPILE_TEST_DEBUG};
+
+ my $pid = open3($stdin, '>&STDERR', $stderr, $^X, @switches, '-c', $file);
+ binmode $stderr, ':crlf' if $^O eq 'MSWin32';
+ my @_warnings = <$stderr>;
+ waitpid($pid, 0);
+ is($?, 0, "$file compiled ok");
+
+ shift @_warnings if @_warnings and $_warnings[0] =~ /^Using .*\bblib/
+ and not eval { +require blib; blib->VERSION('1.01') };
+
+ # in older perls, -c output is simply the file portion of the path being tested
+ if (@_warnings = grep { !/\bsyntax OK$/ }
+ grep { chomp; $_ ne (File::Spec->splitpath($file))[2] } @_warnings)
+ {
+ warn @_warnings;
+ push @warnings, @_warnings;
+ }
+} }
+
+
+
+is(scalar(@warnings), 0, 'no warnings found')
+ or diag 'got warnings: ', ( Test::More->can('explain') ? Test::More::explain(\@warnings) : join("\n", '', @warnings) ) if $ENV{AUTHOR_TESTING};
+
+
--- /dev/null
+do { my $x = {
+ 'configure' => {
+ 'requires' => {
+ 'ExtUtils::MakeMaker' => '0'
+ }
+ },
+ 'develop' => {
+ 'requires' => {
+ 'Dist::Zilla' => '5',
+ 'Dist::Zilla::Plugin::ConsistentVersionTest' => '0',
+ 'Dist::Zilla::PluginBundle::Author::CCM' => '0',
+ 'Dist::Zilla::PluginBundle::Filter' => '0',
+ 'Pod::Coverage::TrustPod' => '0',
+ 'Software::License::MIT' => '0',
+ 'Test::CPAN::Changes' => '0.19',
+ 'Test::CPAN::Meta' => '0',
+ 'Test::ConsistentVersion' => '0',
+ 'Test::EOL' => '0',
+ 'Test::MinimumVersion' => '0',
+ 'Test::More' => '0.96',
+ 'Test::NoTabs' => '0',
+ 'Test::Perl::Critic' => '0',
+ 'Test::Pod' => '1.41',
+ 'Test::Pod::Coverage' => '1.08',
+ 'Test::Pod::No404s' => '0',
+ 'Test::Portability::Files' => '0'
+ }
+ },
+ 'runtime' => {
+ 'requires' => {
+ 'Carp' => '0',
+ 'Exporter' => '0',
+ 'Getopt::Long' => '2.38',
+ 'Module::Load' => '0',
+ 'Module::Pluggable' => '0',
+ 'Pod::Usage' => '0',
+ 'Scalar::Util' => '0',
+ 'Time::Piece' => '0',
+ 'XML::Entities' => '0',
+ 'XML::Parser::Lite' => '0',
+ 'parent' => '0',
+ 'strict' => '0',
+ 'warnings' => '0'
+ }
+ },
+ 'test' => {
+ 'recommends' => {
+ 'CPAN::Meta' => '2.120900'
+ },
+ 'requires' => {
+ 'ExtUtils::MakeMaker' => '0',
+ 'File::Spec' => '0',
+ 'IO::Handle' => '0',
+ 'IPC::Open3' => '0',
+ 'Test::More' => '0',
+ 'perl' => '5.006'
+ }
+ }
+ };
+ $x;
+ }
\ No newline at end of file
--- /dev/null
+#!perl
+
+use strict;
+use warnings;
+
+# This test was generated by Dist::Zilla::Plugin::Test::ReportPrereqs 0.027
+
+use Test::More tests => 1;
+
+use ExtUtils::MakeMaker;
+use File::Spec;
+
+# from $version::LAX
+my $lax_version_re =
+ qr/(?: undef | (?: (?:[0-9]+) (?: \. | (?:\.[0-9]+) (?:_[0-9]+)? )?
+ |
+ (?:\.[0-9]+) (?:_[0-9]+)?
+ ) | (?:
+ v (?:[0-9]+) (?: (?:\.[0-9]+)+ (?:_[0-9]+)? )?
+ |
+ (?:[0-9]+)? (?:\.[0-9]+){2,} (?:_[0-9]+)?
+ )
+ )/x;
+
+# hide optional CPAN::Meta modules from prereq scanner
+# and check if they are available
+my $cpan_meta = "CPAN::Meta";
+my $cpan_meta_pre = "CPAN::Meta::Prereqs";
+my $HAS_CPAN_META = eval "require $cpan_meta; $cpan_meta->VERSION('2.120900')" && eval "require $cpan_meta_pre"; ## no critic
+
+# Verify requirements?
+my $DO_VERIFY_PREREQS = 1;
+
+sub _max {
+ my $max = shift;
+ $max = ( $_ > $max ) ? $_ : $max for @_;
+ return $max;
+}
+
+sub _merge_prereqs {
+ my ($collector, $prereqs) = @_;
+
+ # CPAN::Meta::Prereqs object
+ if (ref $collector eq $cpan_meta_pre) {
+ return $collector->with_merged_prereqs(
+ CPAN::Meta::Prereqs->new( $prereqs )
+ );
+ }
+
+ # Raw hashrefs
+ for my $phase ( keys %$prereqs ) {
+ for my $type ( keys %{ $prereqs->{$phase} } ) {
+ for my $module ( keys %{ $prereqs->{$phase}{$type} } ) {
+ $collector->{$phase}{$type}{$module} = $prereqs->{$phase}{$type}{$module};
+ }
+ }
+ }
+
+ return $collector;
+}
+
+my @include = qw(
+
+);
+
+my @exclude = qw(
+
+);
+
+# Add static prereqs to the included modules list
+my $static_prereqs = do './t/00-report-prereqs.dd';
+
+# Merge all prereqs (either with ::Prereqs or a hashref)
+my $full_prereqs = _merge_prereqs(
+ ( $HAS_CPAN_META ? $cpan_meta_pre->new : {} ),
+ $static_prereqs
+);
+
+# Add dynamic prereqs to the included modules list (if we can)
+my ($source) = grep { -f } 'MYMETA.json', 'MYMETA.yml';
+my $cpan_meta_error;
+if ( $source && $HAS_CPAN_META
+ && (my $meta = eval { CPAN::Meta->load_file($source) } )
+) {
+ $full_prereqs = _merge_prereqs($full_prereqs, $meta->prereqs);
+}
+else {
+ $cpan_meta_error = $@; # capture error from CPAN::Meta->load_file($source)
+ $source = 'static metadata';
+}
+
+my @full_reports;
+my @dep_errors;
+my $req_hash = $HAS_CPAN_META ? $full_prereqs->as_string_hash : $full_prereqs;
+
+# Add static includes into a fake section
+for my $mod (@include) {
+ $req_hash->{other}{modules}{$mod} = 0;
+}
+
+for my $phase ( qw(configure build test runtime develop other) ) {
+ next unless $req_hash->{$phase};
+ next if ($phase eq 'develop' and not $ENV{AUTHOR_TESTING});
+
+ for my $type ( qw(requires recommends suggests conflicts modules) ) {
+ next unless $req_hash->{$phase}{$type};
+
+ my $title = ucfirst($phase).' '.ucfirst($type);
+ my @reports = [qw/Module Want Have/];
+
+ for my $mod ( sort keys %{ $req_hash->{$phase}{$type} } ) {
+ next if $mod eq 'perl';
+ next if grep { $_ eq $mod } @exclude;
+
+ my $file = $mod;
+ $file =~ s{::}{/}g;
+ $file .= ".pm";
+ my ($prefix) = grep { -e File::Spec->catfile($_, $file) } @INC;
+
+ my $want = $req_hash->{$phase}{$type}{$mod};
+ $want = "undef" unless defined $want;
+ $want = "any" if !$want && $want == 0;
+
+ my $req_string = $want eq 'any' ? 'any version required' : "version '$want' required";
+
+ if ($prefix) {
+ my $have = MM->parse_version( File::Spec->catfile($prefix, $file) );
+ $have = "undef" unless defined $have;
+ push @reports, [$mod, $want, $have];
+
+ if ( $DO_VERIFY_PREREQS && $HAS_CPAN_META && $type eq 'requires' ) {
+ if ( $have !~ /\A$lax_version_re\z/ ) {
+ push @dep_errors, "$mod version '$have' cannot be parsed ($req_string)";
+ }
+ elsif ( ! $full_prereqs->requirements_for( $phase, $type )->accepts_module( $mod => $have ) ) {
+ push @dep_errors, "$mod version '$have' is not in required range '$want'";
+ }
+ }
+ }
+ else {
+ push @reports, [$mod, $want, "missing"];
+
+ if ( $DO_VERIFY_PREREQS && $type eq 'requires' ) {
+ push @dep_errors, "$mod is not installed ($req_string)";
+ }
+ }
+ }
+
+ if ( @reports ) {
+ push @full_reports, "=== $title ===\n\n";
+
+ my $ml = _max( map { length $_->[0] } @reports );
+ my $wl = _max( map { length $_->[1] } @reports );
+ my $hl = _max( map { length $_->[2] } @reports );
+
+ if ($type eq 'modules') {
+ splice @reports, 1, 0, ["-" x $ml, "", "-" x $hl];
+ push @full_reports, map { sprintf(" %*s %*s\n", -$ml, $_->[0], $hl, $_->[2]) } @reports;
+ }
+ else {
+ splice @reports, 1, 0, ["-" x $ml, "-" x $wl, "-" x $hl];
+ push @full_reports, map { sprintf(" %*s %*s %*s\n", -$ml, $_->[0], $wl, $_->[1], $hl, $_->[2]) } @reports;
+ }
+
+ push @full_reports, "\n";
+ }
+ }
+}
+
+if ( @full_reports ) {
+ diag "\nVersions for all modules listed in $source (including optional ones):\n\n", @full_reports;
+}
+
+if ( $cpan_meta_error || @dep_errors ) {
+ diag "\n*** WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING ***\n";
+}
+
+if ( $cpan_meta_error ) {
+ my ($orig_source) = grep { -f } 'MYMETA.json', 'MYMETA.yml';
+ diag "\nCPAN::Meta->load_file('$orig_source') failed with: $cpan_meta_error\n";
+}
+
+if ( @dep_errors ) {
+ diag join("\n",
+ "\nThe following REQUIRED prerequisites were not satisfied:\n",
+ @dep_errors,
+ "\n"
+ );
+}
+
+pass;
+
+# vim: ts=4 sts=4 sw=4 et:
--- /dev/null
+#!perl
+
+use strict;
+use warnings;
+
+use Test::Perl::Critic (-profile => "perlcritic.rc") x!! -e "perlcritic.rc";
+all_critic_ok();
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::EOL 0.19
+
+use Test::More 0.88;
+use Test::EOL;
+
+my @files = (
+ 'bin/homebank2ledger',
+ 'lib/App/HomeBank2Ledger.pm',
+ 'lib/App/HomeBank2Ledger/Formatter.pm',
+ 'lib/App/HomeBank2Ledger/Formatter/Beancount.pm',
+ 'lib/App/HomeBank2Ledger/Formatter/Ledger.pm',
+ 'lib/App/HomeBank2Ledger/Ledger.pm',
+ 'lib/App/HomeBank2Ledger/Util.pm',
+ 'lib/File/HomeBank.pm',
+ 't/00-compile.t',
+ 't/00-report-prereqs.dd',
+ 't/00-report-prereqs.t',
+ 'xt/author/critic.t',
+ 'xt/author/eol.t',
+ 'xt/author/minimum-version.t',
+ 'xt/author/no-tabs.t',
+ 'xt/author/pod-coverage.t',
+ 'xt/author/pod-no404s.t',
+ 'xt/author/pod-syntax.t',
+ 'xt/author/portability.t',
+ 'xt/release/consistent-version.t',
+ 'xt/release/cpan-changes.t',
+ 'xt/release/distmeta.t'
+);
+
+eol_unix_ok($_, { trailing_whitespace => 1 }) foreach @files;
+done_testing;
--- /dev/null
+use strict;
+use warnings;
+
+use Test::More;
+use Test::MinimumVersion;
+all_minimum_version_ok( qq{5.14} );
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::NoTabs 0.15
+
+use Test::More 0.88;
+use Test::NoTabs;
+
+my @files = (
+ 'bin/homebank2ledger',
+ 'lib/App/HomeBank2Ledger.pm',
+ 'lib/App/HomeBank2Ledger/Formatter.pm',
+ 'lib/App/HomeBank2Ledger/Formatter/Beancount.pm',
+ 'lib/App/HomeBank2Ledger/Formatter/Ledger.pm',
+ 'lib/App/HomeBank2Ledger/Ledger.pm',
+ 'lib/App/HomeBank2Ledger/Util.pm',
+ 'lib/File/HomeBank.pm',
+ 't/00-compile.t',
+ 't/00-report-prereqs.dd',
+ 't/00-report-prereqs.t',
+ 'xt/author/critic.t',
+ 'xt/author/eol.t',
+ 'xt/author/minimum-version.t',
+ 'xt/author/no-tabs.t',
+ 'xt/author/pod-coverage.t',
+ 'xt/author/pod-no404s.t',
+ 'xt/author/pod-syntax.t',
+ 'xt/author/portability.t',
+ 'xt/release/consistent-version.t',
+ 'xt/release/cpan-changes.t',
+ 'xt/release/distmeta.t'
+);
+
+notabs_ok($_) foreach @files;
+done_testing;
--- /dev/null
+#!perl
+# This file was automatically generated by Dist::Zilla::Plugin::PodCoverageTests.
+
+use Test::Pod::Coverage 1.08;
+use Pod::Coverage::TrustPod;
+
+all_pod_coverage_ok({ coverage_class => 'Pod::Coverage::TrustPod' });
--- /dev/null
+#!perl
+
+use strict;
+use warnings;
+use Test::More;
+
+foreach my $env_skip ( qw(
+ SKIP_POD_NO404S
+ AUTOMATED_TESTING
+) ){
+ plan skip_all => "\$ENV{$env_skip} is set, skipping"
+ if $ENV{$env_skip};
+}
+
+eval "use Test::Pod::No404s";
+if ( $@ ) {
+ plan skip_all => 'Test::Pod::No404s required for testing POD';
+}
+else {
+ all_pod_files_ok();
+}
--- /dev/null
+#!perl
+# This file was automatically generated by Dist::Zilla::Plugin::PodSyntaxTests.
+use strict; use warnings;
+use Test::More;
+use Test::Pod 1.41;
+
+all_pod_files_ok();
--- /dev/null
+use strict;
+use warnings;
+
+use Test::More;
+
+eval 'use Test::Portability::Files';
+plan skip_all => 'Test::Portability::Files required for testing portability'
+ if $@;
+
+run_tests();
--- /dev/null
+use strict;
+use warnings;
+
+use Test::More;
+
+eval "use Test::ConsistentVersion";
+plan skip_all => "Test::ConsistentVersion required for this test"
+ if $@;
+
+Test::ConsistentVersion::check_consistent_versions();
--- /dev/null
+use strict;
+use warnings;
+
+# this test was generated with Dist::Zilla::Plugin::Test::CPAN::Changes 0.012
+
+use Test::More 0.96 tests => 1;
+use Test::CPAN::Changes;
+subtest 'changes_ok' => sub {
+ changes_file_ok('Changes');
+};
--- /dev/null
+#!perl
+# This file was automatically generated by Dist::Zilla::Plugin::MetaTests.
+
+use Test::CPAN::Meta;
+
+meta_yaml_ok();