Release 0.006 solo-0.006
authorCharles McGarvey <chazmcgarvey@brokenzipper.com>
Tue, 3 Sep 2019 02:03:40 +0000 (20:03 -0600)
committerCharles McGarvey <chazmcgarvey@brokenzipper.com>
Tue, 3 Sep 2019 02:03:40 +0000 (20:03 -0600)
README [new file with mode: 0644]
homebank2ledger [new file with mode: 0755]

diff --git a/README b/README
new file mode 100644 (file)
index 0000000..7f25f53
--- /dev/null
+++ b/README
@@ -0,0 +1,299 @@
+NAME
+
+    homebank2ledger - A tool to convert HomeBank files to Ledger format
+
+VERSION
+
+    version 0.006
+
+SYNOPSIS
+
+        homebank2ledger --input FILEPATH [--output FILEPATH] [--format FORMAT]
+                        [--version|--help|--manual] [--account-width NUM]
+                        [--accounts|--no-accounts] [--payees|--no-payees]
+                        [--tags|--no-tags] [--commodities|--no-commodities]
+                        [--opening-date DATE]
+                        [--rename-account STR]... [--exclude-account STR]...
+
+DESCRIPTION
+
+    homebank2ledger converts HomeBank <http://homebank.free.fr/> files to a
+    format usable by Ledger <https://www.ledger-cli.org/>. It can also
+    convert directly to the similar Beancount <http://furius.ca/beancount/>
+    format.
+
+    This software is EXPERIMENTAL, in early development. Its interface may
+    change without notice.
+
+    I wrote 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.
+
+ Features
+
+      * Converts HomeBank accounts and categories into a typical set of
+      double-entry accounts.
+
+      * Retains HomeBank metadata, including payees and tags.
+
+      * Offers some customization of the output ledger, like account
+      renaming.
+
+    This program is feature-complete in my opinion (well, almost -- see
+    "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!
+
+ 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.
+
+INSTALL
+
+    There are several ways to install homebank2ledger to your system.
+
+ using cpanm
+
+    You can install homebank2ledger using cpanm. If you have a local perl
+    (plenv, perlbrew, etc.), you can just do:
+
+        cpanm App::Homebank2Ledger
+
+    to install the homebank2ledger executable and its dependencies. The
+    executable will be installed to your perl's bin path, like
+    ~/perl5/perlbrew/bin/homebank2ledger.
+
+    If you're installing to your system perl, you can do:
+
+        cpanm --sudo App::Homebank2Ledger
+
+    to install the homebank2ledger executable to a system directory, like
+    /usr/local/bin/homebank2ledger (depending on your perl).
+
+ Downloading just the executable
+
+    You may also choose to download homebank2ledger as a single executable,
+    like this:
+
+        curl -OL https://raw.githubusercontent.com/chazmcgarvey/homebank2ledger/solo/homebank2ledger
+        chmod +x homebank2ledger
+
+ For developers
+
+    If you're a developer and want to hack on the source, clone the
+    repository and pull the dependencies:
+
+        git clone https://github.com/chazmcgarvey/homebank2ledger.git
+        cd homebank2ledger
+        make bootstrap      # installs dependencies; requires cpanm
+
+OPTIONS
+
+ --version
+
+    Print the version and exit.
+
+    Alias: -V
+
+ --help
+
+    Print help/usage info and exit.
+
+    Alias: -h, -?
+
+ --manual
+
+    Print the full manual and exit.
+
+    Alias: --man
+
+ --input FILEPATH
+
+    Specify the path to the HomeBank file to read (must already exist).
+
+    Alias: --file, -i
+
+ --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 STDOUT.
+
+    Alias: -o
+
+ --format STR
+
+    Specify the output file format. If provided, must be one of:
+
+      * ledger
+
+      * beancount
+
+ --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.
+
+ --accounts
+
+    Enables account declarations.
+
+    Defaults to enabled; use --no-accounts to disable.
+
+ --payees
+
+    Enables payee declarations.
+
+    Defaults to enabled; use --no-payees to disable.
+
+ --tags
+
+    Enables tag declarations.
+
+    Defaults to enabled; use --no-tags to disable.
+
+ --commodities
+
+    Enables commodity declarations.
+
+    Defaults to enabled; use --no-commodities to disable.
+
+ --budget
+
+    Enables budget transactions.
+
+    Budget transactions are only supported by the Ledger format (for now).
+    This option is silently ignored otherwise.
+
+    Defaults to enabled; use --no-budget to disable.
+
+ --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.
+
+ --rename-account STR
+
+    Specifies a mapping for renaming accounts in the output. By default
+    homebank2ledger tries to come up with sensible account names (based on
+    your HomeBank accounts and categories) that fit into five root
+    accounts:
+
+      * Assets
+
+      * Liabilities
+
+      * Equity
+
+      * Income
+
+      * Expenses
+
+    The value of the argument must be of the form "REGEXP=REPLACEMENT". See
+    "EXAMPLES".
+
+    Can be repeated to rename multiple accounts.
+
+ --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.
+
+EXAMPLES
+
+ 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
+
+ Account renaming
+
+    With the "--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.
+
+ 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
+
+CAVEATS
+
+      * I didn't intend to make this a releasable robust product, so it's
+      lacking tests.
+
+      * Scheduled transactions are not (yet) converted.
+
+      * There are some minor formatting tweaks I will make (e.g.
+      consolidate transaction tags and payees)
+
+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
+
diff --git a/homebank2ledger b/homebank2ledger
new file mode 100755 (executable)
index 0000000..237ae2a
--- /dev/null
@@ -0,0 +1,415 @@
+#!/usr/bin/env perl
+# ABSTRACT: A tool to convert HomeBank files to Ledger format
+# PODNAME: homebank2ledger
+
+
+
+# This chunk of stuff was generated by App::FatPacker. To find the original
+# file's code, look for the end of this BEGIN block or the string 'FATPACK'
+BEGIN {
+my %fatpacked;
+
+$fatpacked{"App/HomeBank2Ledger.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER';
+  package App::HomeBank2Ledger;use warnings;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.006';my%ACCOUNT_TYPES=(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)}if (!$opts->{input}){print STDERR "Input file is required.\n";exit(1)}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$default_account_income='Income:Unknown';my$default_account_expenses='Expenses:Unknown';my$ledger=App::HomeBank2Ledger::Ledger->new;my$transactions=$homebank->sorted_transactions;my$accounts=$homebank->accounts;my$categories=$homebank->categories;my@budget;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}";if ($opts->{budget}&& $category->{flags}{budget}){for my$month_num ($category->{flags}{custom}? (1 .. 12): 0){my$amount=$category->{budget_amounts}[$month_num]|| 0;next if!$amount &&!$category->{flags}{forced};$budget[$month_num]{$category->{ledger_name}}=$amount}}}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/}}while (my ($re,$replacement)=each %{$opts->{rename_accounts}}){$default_account_income =~ s/$re/$replacement/;$default_account_expenses =~ s/$re/$replacement/}my$has_initial_balance=grep {$_->{initial}&&!$_->{excluded}}@$accounts;if ($opts->{accounts}){my@accounts=map {$_->{ledger_name}}grep {!$_->{excluded}}@$accounts,@$categories;push@accounts,$default_account_income if!grep {$_ eq $default_account_income}@accounts;push@accounts,$default_account_expenses if!grep {$_ eq $default_account_expenses}@accounts;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}}my$first_date;if ($has_initial_balance){$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,})}if ($opts->{budget}){my ($first_year)=$first_date =~ /^(\d{4})/;for my$month_num (0 .. 12){next if!$budget[$month_num];my$payee='Monthly';if (0 < $month_num){my$year=$first_year;$year += 1 if sprintf('%04d-%02d-99',$first_year,$month_num)lt $first_date;my$date=sprintf('%04d-%02d',$year,$month_num);$payee="Every 12 months from ${date}"}my@postings;for my$account (sort keys %{$budget[$month_num]}){my$amount=$budget[$month_num]{$account};push@postings,{account=>$account,amount=>-$amount,commodity=>$commodities{$homebank->base_currency},}}push@postings,{account=>'Assets',};$ledger->add_transactions({date=>'~',payee=>$payee,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}|| '';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},note=>$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},note=>$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}: $amount < 0 ? $default_account_income : $default_account_expenses;push@postings,{account=>$other_account,commodity=>$commodities{$account->{currency}},amount=>$amount,payee=>$payee->{name},note=>$memo,status=>$status,tags=>$tags,}}}else {my$amount=-$transaction->{amount};my$category=$homebank->find_category_by_key($transaction->{category});my$other_account=$category ? $category->{ledger_name}: $amount < 0 ? $default_account_income : $default_account_expenses;push@postings,{account=>$other_account,commodity=>$commodities{$account->{currency}},amount=>$amount,payee=>$payee->{name},note=>$memo,status=>$status,tags=>$tags,}}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,budget=>1,opening_date=>'',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},'budget!'=>\$opts{budget},'opening-date=s'=>\$opts{opening_date},'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};$opts{budget}=0 if lc($opts{format})ne 'ledger';return \%opts}sub _split_tags {my$tags=shift;return [split(/\h+/,$tags || '')]}1;
+APP_HOMEBANK2LEDGER
+
+$fatpacked{"App/HomeBank2Ledger/Formatter.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER';
+  package App::HomeBank2Ledger::Formatter;use warnings;use strict;use Module::Load;use Module::Pluggable search_path=>[__PACKAGE__],sub_name=>'available_formatters';our$VERSION='0.006';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}){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 {die "Unimplemented\n"}sub type {shift->{type}}sub name {shift->{name}}sub file {shift->{file}}sub account_width {shift->{account_width}|| 40}1;
+APP_HOMEBANK2LEDGER_FORMATTER
+
+$fatpacked{"App/HomeBank2Ledger/Formatter/Beancount.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT';
+  package App::HomeBank2Ledger::Formatter::Beancount;use v5.10.1;use warnings;use strict;use App::HomeBank2Ledger::Util qw(commify rtrim);use Scalar::Util qw(looks_like_number);use parent 'App::HomeBank2Ledger::Formatter';our$VERSION='0.006';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_transactions($ledger),);return join($/,map {rtrim($_)}@out)}sub format_header {my$self=shift;my@out;if (my$name=$self->name){push@out,"; Name: $name"}if (my$file=$self->file){push@out,"; File: $file"}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;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 || ' *',($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)}my$metadata=$transaction->{metadata}|| {};for my$key (sort keys %$metadata){my$value=looks_like_number($metadata->{$key})? $metadata->{$key}: $self->_format_string($metadata->{$key});push@out,"    ; ${key}: ${value}"}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,'  ';if (defined$posting->{amount}){push@line,$self->_format_amount($posting->{amount},$posting->{commodity});my$lot_price=$posting->{lot_price};my$lot_date=$posting->{lot_date};my$lot_ref=$posting->{lot_ref};if ($lot_price || $lot_date || $lot_ref){push@line,' {',join(', ',$lot_price ? $self->_format_amount($lot_price->{amount},$lot_price->{commodity}): (),$lot_date ? $lot_date : (),$lot_ref ? $self->_format_string($lot_ref): (),),'}'}if (my$cost=$posting->{total_cost}// $posting->{cost}){my$is_total=defined$posting->{total_cost};my$cost_symbol=$is_total ? '@@' : '@';push@line,' ',$cost_symbol,' ',$self->_format_amount($cost->{amount},$cost->{commodity})}}push@out,join('',@line);my$metadata=$posting->{metadata}|| {};for my$key (sort keys %$metadata){my$value=looks_like_number($metadata->{$key})? $metadata->{$key}: $self->_format_string($metadata->{$key});push@out,"      ; ${key}: ${value}"}}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));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){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){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;
+APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT
+
+$fatpacked{"App/HomeBank2Ledger/Formatter/Ledger.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER_LEDGER';
+  package App::HomeBank2Ledger::Formatter::Ledger;use v5.10.1;use warnings;use strict;use App::HomeBank2Ledger::Util qw(commify rtrim);use parent 'App::HomeBank2Ledger::Formatter';our$VERSION='0.006';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"}if (my$file=$self->file){push@out,"; File: $file"}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;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;push@out,sprintf('%s%s%s%s',$date,$status_symbol && " ${status_symbol}",$payee && " $payee",$memo && "  ; $memo",);my$metadata=$transaction->{metadata}|| {};for my$key (sort keys %$metadata){my$value=$self->_format_string($metadata->{$key});push@out,"    ; ${key}: ${value}"}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,'  ';if (defined$posting->{amount}){push@line,$self->_format_amount($posting->{amount},$posting->{commodity});if (my$price=$posting->{lot_price}){my$is_fixed=$posting->{lot_fixed};my$fixed_symbol=$is_fixed ? '=' : '';push@line," {${fixed_symbol}",$self->_format_amount($price->{amount},$price->{commodity}),'}'}if (my$lot_date=$posting->{lot_date}){push@line," [$posting->{lot_date}]"}if (my$cost=$posting->{total_cost}// $posting->{cost}){my$is_total=defined$posting->{total_cost};my$cost_symbol=$is_total ? '@@' : '@';push@line,' ',$cost_symbol,' ',$self->_format_amount($cost->{amount},$cost->{commodity})}}if (my$note=$posting->{note}){$note=$self->_format_string($note);push@line,"  ; $note" if$note ne $memo}push@out,join('',@line);my$metadata=$posting->{metadata}|| {};for my$key (sort keys %$metadata){my$value=$self->_format_string($metadata->{$key});push@out,"      ; ${key}: ${value}"}if (my$posting_payee=$posting->{payee}){$posting_payee=$self->_format_string($posting_payee);push@out,"      ; Payee: $posting_payee" if$posting_payee ne $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 _quote_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));my$num=join($commodity->{dchar},commify($whole,$commodity->{gchar}),$fraction);my$symbol=$commodity->{symbol};$symbol=$self->_quote_string($symbol)if$symbol =~ /[0-9\s]/;$num=$commodity->{syprf}? "$symbol $num" : "$num $symbol";return$num}1;
+APP_HOMEBANK2LEDGER_FORMATTER_LEDGER
+
+$fatpacked{"App/HomeBank2Ledger/Ledger.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_LEDGER';
+  package App::HomeBank2Ledger::Ledger;use warnings;use strict;our$VERSION='0.006';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}|| []}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;
+APP_HOMEBANK2LEDGER_LEDGER
+
+$fatpacked{"App/HomeBank2Ledger/Util.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_UTIL';
+  package App::HomeBank2Ledger::Util;use warnings;use strict;use Exporter qw(import);our$VERSION='0.006';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;
+APP_HOMEBANK2LEDGER_UTIL
+
+$fatpacked{"File/HomeBank.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'FILE_HOMEBANK';
+  package File::HomeBank;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.006';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){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){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){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){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){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;push@candidates,$t}my$transaction_day=_ymd_to_julian($transaction->{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=@_;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'){push@payees,\%attr}elsif ($node eq 'cur'){$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'){my$flags=delete$attr{flags}|| 0;while (my ($shift,$name)=each%CATEGORY_FLAGS){$attr{flags}{$name}=$flags & (1 << $shift)? 1 : 0}for my$bnum (0 .. 12){$attr{budget_amounts}[$bnum]=delete$attr{"b$bnum"}if$attr{"b$bnum"}}push@categories,\%attr}elsif ($node eq 'ope'){$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;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;
+FILE_HOMEBANK
+
+s/^  //mg for values %fatpacked;
+
+my $class = 'FatPacked::'.(0+\%fatpacked);
+no strict 'refs';
+*{"${class}::files"} = sub { keys %{$_[0]} };
+
+if ($] < 5.008) {
+  *{"${class}::INC"} = sub {
+    if (my $fat = $_[0]{$_[1]}) {
+      my $pos = 0;
+      my $last = length $fat;
+      return (sub {
+        return 0 if $pos == $last;
+        my $next = (1 + index $fat, "\n", $pos) || $last;
+        $_ .= substr $fat, $pos, $next - $pos;
+        $pos = $next;
+        return 1;
+      });
+    }
+  };
+}
+
+else {
+  *{"${class}::INC"} = sub {
+    if (my $fat = $_[0]{$_[1]}) {
+      open my $fh, '<', \$fat
+        or die "FatPacker error loading $_[1] (could be a perl installation issue?)";
+      return $fh;
+    }
+    return;
+  };
+}
+
+unshift @INC, bless \%fatpacked, $class;
+  } # END OF FATPACK CODE
+
+
+
+use warnings;
+use strict;
+
+use App::HomeBank2Ledger;
+
+our $VERSION = '0.006'; # 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.006
+
+=head1 SYNOPSIS
+
+    homebank2ledger --input FILEPATH [--output FILEPATH] [--format FORMAT]
+                    [--version|--help|--manual] [--account-width NUM]
+                    [--accounts|--no-accounts] [--payees|--no-payees]
+                    [--tags|--no-tags] [--commodities|--no-commodities]
+                    [--opening-date DATE]
+                    [--rename-account STR]... [--exclude-account STR]...
+
+=head1 DESCRIPTION
+
+F<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 F<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 INSTALL
+
+There are several ways to install F<homebank2ledger> to your system.
+
+=head2 using cpanm
+
+You can install F<homebank2ledger> using L<cpanm>. If you have a local perl (plenv, perlbrew, etc.),
+you can just do:
+
+    cpanm App::Homebank2Ledger
+
+to install the F<homebank2ledger> executable and its dependencies. The executable will be installed
+to your perl's bin path, like F<~/perl5/perlbrew/bin/homebank2ledger>.
+
+If you're installing to your system perl, you can do:
+
+    cpanm --sudo App::Homebank2Ledger
+
+to install the F<homebank2ledger> executable to a system directory, like
+F</usr/local/bin/homebank2ledger> (depending on your perl).
+
+=head2 Downloading just the executable
+
+You may also choose to download F<homebank2ledger> as a single executable, like this:
+
+    curl -OL https://raw.githubusercontent.com/chazmcgarvey/homebank2ledger/solo/homebank2ledger
+    chmod +x homebank2ledger
+
+=head2 For developers
+
+If you're a developer and want to hack on the source, clone the repository and pull the
+dependencies:
+
+    git clone https://github.com/chazmcgarvey/homebank2ledger.git
+    cd homebank2ledger
+    make bootstrap      # installs dependencies; requires cpanm
+
+=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 --budget
+
+Enables budget transactions.
+
+Budget transactions are only supported by the Ledger format (for now). This option is silently
+ignored otherwise.
+
+Defaults to enabled; use C<--no-budget> 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 --rename-account STR
+
+Specifies a mapping for renaming accounts in the output. By default F<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 *
+
+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
This page took 0.03436 seconds and 4 git commands to generate.