2 # ABSTRACT: A tool to convert HomeBank files to Ledger format
3 # PODNAME: homebank2ledger
7 # This chunk of stuff was generated by App::FatPacker. To find the original
8 # file's code, look for the end of this BEGIN block or the string 'FATPACK'
12 $fatpacked{"App/HomeBank2Ledger.pm"} = '#line '.(1+__LINE__
).' "'.__FILE__
."\"\n".<<'APP_HOMEBANK2LEDGER';
13 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.010';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$memo=$transaction->{wording
}|| '';my$payee=$homebank->find_payee_by_key($transaction->{payee
});my$tags=_split_tags
($transaction->{tags
});my$date=$transaction->{date
};my$code=$transaction->{paymode
}=~ /^(?:check
|epayment
)$/ ? $transaction->{info
}: undef;my@postings;push@postings,{date
=>$date,account
=>$account->{ledger_name
},amount
=>$amount,commodity
=>$commodities{$account->{currency
}},payee
=>$payee->{name
},note
=>$memo,status
=>$status,tags
=>$tags,};if ($transaction->{dst_account
}){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_date=$paired_transaction && $paired_transaction->{date
};my$paired_payee=$homebank->find_payee_by_key($paired_transaction->{payee
});push@postings,{date
=>$paired_date,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
=>$date,payee
=>$payee->{name
},code
=>$code,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;
16 $fatpacked{"App/HomeBank2Ledger/Formatter.pm"} = '#line '.(1+__LINE__
).' "'.__FILE__
."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER';
17 package App::HomeBank2Ledger::Formatter;use warnings;use strict;use Module::Load;use Module::Pluggable search_path=>[__PACKAGE__],sub_name=>'available_formatters';our$VERSION='0.010';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;
18 APP_HOMEBANK2LEDGER_FORMATTER
20 $fatpacked{"App/HomeBank2Ledger/Formatter/Beancount.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT';
21 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.010';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->{note
}// $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=$posting->{lot}|| {};my$lot_price=$lot->{price}// $posting->{lot_price};my$lot_date=$lot->{date}// $posting->{lot_date};my$lot_ref=$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;
22 APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT
24 $fatpacked{"App/HomeBank2Ledger/Formatter/Ledger.pm"} = '#line '.(1+__LINE__
).' "'.__FILE__
."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER_LEDGER';
25 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.010';my%STATUS_SYMBOLS=(cleared
=>'*',pending
=>'!',);my$SYMBOL=qr![^\s\d.,;:?\!\
-+*/^&|=\<\>\[\]\(\)\{\}\@]+!;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
}or _croak
'Transaction date is required';my$aux_date=$transaction->{aux_date
}|| $transaction->{effective_date
}|| '';my$status=$transaction->{status
}// '';my$code=$transaction->{code
};my$payee=$self->_format_string($transaction->{payee
});my$note=$self->_format_string($transaction->{note
}// $transaction->{memo
});my@postings=@{$transaction->{postings
}|| _croak
'At least one transaction posting is required'};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'}|| ''}}$aux_date='' if$date eq $aux_date;$code =~ s/[\(\)]+// if defined$code;$payee =~ s/(?: )|\t;/ ;/g if defined$payee;my$has_code=defined$code && $code ne '';my$has_payee=defined$payee && $payee ne '';my$has_note=defined$note && $note ne '';push@out,join('',$date,$aux_date && "=${aux_date}",$status_symbol && " ${status_symbol}",$has_code && " (${code})",$has_payee && " ${payee}",$has_note && $has_payee && " ; ${note}",);if ($has_note &&!$has_payee){push@out," ; ${note}"}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
});my$lot=$posting->{lot
}|| {};if (my$lot_price=$lot->{price
}// $posting->{lot_price
}){my$is_fixed=$lot_price->{fixed
}// $posting->{lot_fixed
};my$fixed_symbol=$is_fixed ? '=' : '';push@line," {${fixed_symbol}",$self->_format_amount($lot_price->{amount
},$lot_price->{commodity
}),'}'}if (my$lot_date=$lot->{date
}// $posting->{lot_date
}){push@line," [${lot_date}]"}if (my$lot_note=$self->_format_string($lot->{note
}// $posting->{lot_note
}// '')){$lot_note =~ s/[\(\)]+//;$lot_note =~ s/^\@+//;push@line," (${lot_note})" if$lot_note}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
})}}my$posting_date=$posting->{date
}|| '';my$posting_aux_date=$posting->{aux_date
}|| '';my$posting_note=$self->_format_string($posting->{note
}// $posting->{memo
}// '');$posting_date='' if$posting_date eq $date;$posting_aux_date='' if$posting_aux_date eq $aux_date;$posting_note='' if$has_note && $posting_note eq $note;my$has_posting_note=defined$posting_note && $posting_note ne '';if ($posting_date || $posting_aux_date || $has_posting_note){if ($posting_date || $posting_aux_date){$posting_note=sprintf('[%s%s]%s',$posting_date,$posting_aux_date && "=${posting_aux_date}",$has_posting_note && " ${posting_note}",)}push@line," ; ${posting_note}"}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=$self->_format_string($posting->{payee
}// '')){push@out," ; Payee: $posting_payee" if!$has_payee || $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 // return;$str =~ s/\v//g;return$str}sub _quote_string
{my$self=shift;my$str=shift;$str =~ s/"/\\"/g;return "\"$str\""}sub _format_symbol
{my$self=shift;my$str=shift;return$self->_quote_string($str)if$str !~ /^${SYMBOL}$/;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=$self->_format_symbol($commodity->{symbol
});$num=$commodity->{syprf
}? "$symbol $num" : "$num $symbol";return$num}1;
26 APP_HOMEBANK2LEDGER_FORMATTER_LEDGER
28 $fatpacked{"App/HomeBank2Ledger/Ledger.pm"} = '#line '.(1+__LINE__
).' "'.__FILE__
."\"\n".<<'APP_HOMEBANK2LEDGER_LEDGER';
29 package App::HomeBank2Ledger::Ledger;use warnings;use strict;our$VERSION='0.010';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;
30 APP_HOMEBANK2LEDGER_LEDGER
32 $fatpacked{"App/HomeBank2Ledger/Util.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_UTIL';
33 package App::HomeBank2Ledger::Util;use warnings;use strict;use Exporter qw(import);our$VERSION='0.010';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;
34 APP_HOMEBANK2LEDGER_UTIL
36 $fatpacked{"File/HomeBank.pm"} = '#line '.(1+__LINE__
).' "'.__FILE__
."\"\n".<<'FILE_HOMEBANK';
37 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.010';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 file_version
{shift-
>{homebank
}{version
}}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->{dst_account
};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->{dst_account
};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%homebank;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 'homebank'){$attr{version
}=delete$attr{v
}if$attr{v
};%homebank=%attr}elsif ($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 {homebank
=>\
%homebank,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;
40 s/^ //mg for values %fatpacked;
42 my $class = 'FatPacked::'.(0+\
%fatpacked);
44 *{"${class}::files"} = sub { keys %{$_[0]} };
47 *{"${class}::INC"} = sub {
48 if (my $fat = $_[0]{$_[1]}) {
50 my $last = length $fat;
52 return 0 if $pos == $last;
53 my $next = (1 + index $fat, "\n", $pos) || $last;
54 $_ .= substr $fat, $pos, $next - $pos;
63 *{"${class}::INC"} = sub {
64 if (my $fat = $_[0]{$_[1]}) {
65 open my $fh, '<', \
$fat
66 or die "FatPacker error loading $_[1] (could be a perl installation issue?)";
73 unshift @INC, bless \
%fatpacked, $class;
74 } # END OF FATPACK CODE
81 use App
::HomeBank2Ledger
;
83 our $VERSION = '0.010'; # VERSION
85 App
::HomeBank2Ledger-
>main(@ARGV);
95 homebank2ledger - A tool to convert HomeBank files to Ledger format
103 homebank2ledger --input FILEPATH [--output FILEPATH] [--format FORMAT]
104 [--version|--help|--manual] [--account-width NUM]
105 [--accounts|--no-accounts] [--payees|--no-payees]
106 [--tags|--no-tags] [--commodities|--no-commodities]
107 [--opening-date DATE]
108 [--rename-account STR]... [--exclude-account STR]...
112 F<homebank2ledger> converts L<HomeBank|http://homebank.free.fr/> files to a format usable by
113 L<Ledger|https://www.ledger-cli.org/>. It can also convert directly to the similar
114 L<Beancount|https://beancount.github.io/docs/index.html> format.
116 This software is B<EXPERIMENTAL>, in early development. Its interface may change without notice.
118 I wrote F<homebank2ledger> because I have been maintaining my own personal finances using HomeBank
119 (which is awesome) and I wanted to investigate using plain text accounting programs. It works well
120 enough for my data, but you may be using HomeBank features that I don't so there may be cases this
121 doesn't handle well or at all. Feel free to file a bug report. This script does NOT try to modify
122 the original HomeBank files it converts from, so there won't be any crazy data loss bugs... but no
131 Converts HomeBank accounts and categories into a typical set of double-entry accounts.
135 Retains HomeBank metadata, including payees and tags.
139 Offers some customization of the output ledger, like account renaming.
143 This program is feature-complete in my opinion (well, almost -- see L</CAVEATS>), but if there is
144 anything you think it could do to be even better, feedback is welcome; just file a bug report. Or
145 fork the code and have fun!
149 You can migrate the data you have in HomeBank so you can start maintaining your accounts in Ledger
152 Or if you don't plan to switch completely off of HomeBank, you can continue to maintain your
153 accounts in HomeBank and use this script to also take advantage of the reports Ledger offers.
157 There are several ways to install F<homebank2ledger> to your system.
161 You can install F<homebank2ledger> using L<cpanm>. If you have a local perl (plenv, perlbrew, etc.),
164 cpanm App::Homebank2Ledger
166 to install the F<homebank2ledger> executable and its dependencies. The executable will be installed
167 to your perl's bin path, like F<~/perl5/perlbrew/bin/homebank2ledger>.
169 If you're installing to your system perl, you can do:
171 cpanm --sudo App::Homebank2Ledger
173 to install the F<homebank2ledger> executable to a system directory, like
174 F</usr/local/bin/homebank2ledger> (depending on your perl).
176 =head2 Downloading just the executable
178 You may also choose to download F<homebank2ledger> as a single executable, like this:
180 curl -OL https://raw.githubusercontent.com/chazmcgarvey/homebank2ledger/solo/homebank2ledger
181 chmod +x homebank2ledger
183 =head2 For developers
185 If you're a developer and want to hack on the source, clone the repository and pull the
188 git clone https://github.com/chazmcgarvey/homebank2ledger.git
190 make bootstrap # installs dependencies; requires cpanm
196 Print the version and exit.
202 Print help/usage info and exit.
208 Print the full manual and exit.
212 =head2 --input FILEPATH
214 Specify the path to the HomeBank file to read (must already exist).
216 Alias: C<--file>, C<-i>
218 =head2 --output FILEPATH
220 Specify the path to the Ledger file to write (may not exist yet). If not provided, the formatted
221 ledger will be printed on C<STDOUT>.
227 Specify the output file format. If provided, must be one of:
241 =head2 --account-width NUM
243 Specify the number of characters to reserve for the account column in transactions. Adjusting this
244 can provide prettier formatting of the output.
250 Enables account declarations.
252 Defaults to enabled; use C<--no-accounts> to disable.
256 Enables payee declarations.
258 Defaults to enabled; use C<--no-payees> to disable.
262 Enables tag declarations.
264 Defaults to enabled; use C<--no-tags> to disable.
268 Enables commodity declarations.
270 Defaults to enabled; use C<--no-commodities> to disable.
274 Enables budget transactions.
276 Budget transactions are only supported by the Ledger format (for now). This option is silently
279 Defaults to enabled; use C<--no-budget> to disable.
281 =head2 --opening-date DATE
283 Specify the opening date for the "opening balances" transaction. This transaction is created (if
284 needed) to support HomeBank's ability to configure accounts with opening balances.
286 Date must be in the form "YYYY-MM-DD". Defaults to the date of the first transaction.
288 =head2 --rename-account STR
290 Specifies a mapping for renaming accounts in the output. By default F<homebank2ledger> tries to come
291 up with sensible account names (based on your HomeBank accounts and categories) that fit into five
318 The value of the argument must be of the form "REGEXP=REPLACEMENT". See L</EXAMPLES>.
320 Can be repeated to rename multiple accounts.
322 =head2 --exclude-account STR
324 Specifies an account that will not be included in the output. All transactions related to this
325 account will be skipped.
327 Can be repeated to exclude multiple accounts.
333 # Convert homebank.xhb to a Ledger-compatible file:
334 homebank2ledger path/to/homebank.xhb -o ledger.dat
336 # Run the Ledger balance report:
337 ledger -f ledger.dat balance
339 You can also combine this into one command:
341 homebank2ledger path/to/homebank.xhb | ledger -f - balance
343 =head2 Account renaming
345 With the L</"--rename-account STR"> argument, you have some control over the resulting account
346 structure. This may be useful in cases where the organization imposed (or encouraged) by HomeBank
347 doesn't necessarily line up with an ideal double-entry structure.
349 homebank2ledger path/to/homebank.xhb -o ledger.dat \
350 --rename-account '^Assets:Credit Union Savings$=Assets:Bank:Credit Union:Savings' \
351 --rename-account '^Assets:Credit Union Checking$=Assets:Bank:Credit Union:Checking'
353 Multiple accounts can be renamed at the same time because the first part of the mapping is a regular
354 expression. The above example could be written like this:
356 homebank2ledger path/to/homebank.xhb -o ledger.dat \
357 --rename-account '^Assets:Credit Union =Assets:Bank:Credit Union:'
359 You can also merge accounts by simple renaming multiple accounts to the same name:
361 homebank2ledger path/to/homebank.xhb -o ledger.dat \
362 --rename-account '^Liabilities:Chase VISA$=Liabilities:All Credit Cards' \
363 --rename-account '^Liabilities:Amex$=Liabilities:All Credit Cards'
365 If you need to do anything more complicated, of course you can edit the output after converting;
366 it's just plain text.
370 # Convert homebank.xhb to a Beancount-compatible file:
371 homebank2ledger path/to/homebank.xhb -f beancount -o ledger.beancount
373 # Run the balances report:
374 bean-report ledger.beancount balances
382 I didn't intend to make this a releasable robust product, so it's lacking tests.
386 Scheduled transactions are not (yet) converted.
390 There are some minor formatting tweaks I will make (e.g. consolidate transaction tags and payees)
396 Please report any bugs or feature requests on the bugtracker website
397 L<https://github.com/chazmcgarvey/homebank2ledger/issues>
399 When submitting a bug or request, please include a test-file or a
400 patch to an existing test-file that illustrates the bug or desired
405 Charles McGarvey <chazmcgarvey@brokenzipper.com>
407 =head1 COPYRIGHT AND LICENSE
409 This software is Copyright (c) 2019 by Charles McGarvey.
411 This is free software, licensed under:
413 The MIT (X11) License