From: Charles McGarvey Date: Tue, 3 Sep 2019 02:03:40 +0000 (-0600) Subject: Release 0.006 X-Git-Tag: solo-0.006^0 X-Git-Url: https://git.dogcows.com/gitweb?a=commitdiff_plain;h=50ed2389c6d0777365360f9d80c8921f3b389a27;p=chaz%2Fhomebank2ledger Release 0.006 --- 50ed2389c6d0777365360f9d80c8921f3b389a27 diff --git a/README b/README new file mode 100644 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 files to a + format usable by Ledger . It can also + convert directly to the similar 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 + +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 index 0000000..237ae2a --- /dev/null +++ b/homebank2ledger @@ -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 converts L files to a format usable by +L. It can also convert directly to the similar +L format. + +This software is B, in early development. Its interface may change without notice. + +I wrote F 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), 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 to your system. + +=head2 using cpanm + +You can install F using L. If you have a local perl (plenv, perlbrew, etc.), +you can just do: + + cpanm App::Homebank2Ledger + +to install the F 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 executable to a system directory, like +F (depending on your perl). + +=head2 Downloading just the executable + +You may also choose to download F 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. + +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 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. + +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 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 + +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 + +=head1 COPYRIGHT AND LICENSE + +This software is Copyright (c) 2019 by Charles McGarvey. + +This is free software, licensed under: + + The MIT (X11) License + +=cut