Release 0.007
[chaz/homebank2ledger] / homebank2ledger
index 237ae2aa1d8f3d2b5000edab124e48ce6ca7a7f2..c07b9ddd49d68e720c59896ac09cfacf6d893a05 100755 (executable)
@@ -10,31 +10,31 @@ 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;
+  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.007';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;
+  package App::HomeBank2Ledger::Formatter;use warnings;use strict;use Module::Load;use Module::Pluggable search_path=>[__PACKAGE__],sub_name=>'available_formatters';our$VERSION='0.007';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;
+  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.007';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));$fraction ||= 0;my$num=commify($whole);if ($commodity->{frac}){$num .= ".$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;
+  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.007';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));$fraction ||= 0;my$num=commify($whole,$commodity->{gchar});if ($commodity->{frac}){$num .= $commodity->{dchar}.$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;
+  package App::HomeBank2Ledger::Ledger;use warnings;use strict;our$VERSION='0.007';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;
+  package App::HomeBank2Ledger::Util;use warnings;use strict;use Exporter qw(import);our$VERSION='0.007';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;
+  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.007';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;
@@ -80,7 +80,7 @@ use strict;
 
 use App::HomeBank2Ledger;
 
-our $VERSION = '0.006'; # VERSION
+our $VERSION = '0.007'; # VERSION
 
 App::HomeBank2Ledger->main(@ARGV);
 
@@ -96,7 +96,7 @@ homebank2ledger - A tool to convert HomeBank files to Ledger format
 
 =head1 VERSION
 
-version 0.006
+version 0.007
 
 =head1 SYNOPSIS
 
This page took 0.033209 seconds and 4 git commands to generate.