#!/usr/bin/env perl # ABSTRACT: A tool to convert HomeBank files to Ledger format # PODNAME: homebank2ledger # This chunk of stuff was generated by App::FatPacker. To find the original # file's code, look for the end of this BEGIN block or the string 'FATPACK' BEGIN { my %fatpacked; $fatpacked{"App/HomeBank2Ledger.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER'; package App::HomeBank2Ledger;use warnings;use strict;use App::HomeBank2Ledger::Formatter;use App::HomeBank2Ledger::Ledger;use File::HomeBank;use Getopt::Long 2.38 qw(GetOptionsFromArray);use Pod::Usage;our$VERSION='0.006';my%ACCOUNT_TYPES=(bank=>'Assets:Bank',cash=>'Assets:Cash',asset=>'Assets:Fixed Assets',creditcard=>'Liabilities:Credit Card',liability=>'Liabilities',stock=>'Assets:Stock',mutualfund=>'Assets:Mutual Fund',income=>'Income',expense=>'Expenses',equity=>'Equity',);my%STATUS_SYMBOLS=(cleared=>'cleared',reconciled=>'cleared',remind=>'pending',);my$UNKNOWN_ACCOUNT='Assets:Unknown';my$OPENING_BALANCES_ACCOUNT='Equity:Opening Balances';sub main {my$class=shift;my$self=bless {},$class;my$opts=$self->parse_args(@_);if ($opts->{version}){print "homebank2ledger ${VERSION}\n";exit 0}if ($opts->{help}){pod2usage(-exitval=>0,-verbose=>99,-sections=>[qw(NAME SYNOPSIS OPTIONS)])}if ($opts->{manual}){pod2usage(-exitval=>0,-verbose=>2)}if (!$opts->{input}){print STDERR "Input file is required.\n";exit(1)}my$homebank=File::HomeBank->new(file=>$opts->{input});my$formatter=eval {$self->formatter($homebank,$opts)};if (my$err=$@){if ($err =~ /^Invalid formatter/){print STDERR "Invalid format: $opts->{format}\n";exit 2}die$err}my$ledger=$self->convert_homebank_to_ledger($homebank,$opts);$self->print_to_file($formatter->format($ledger),$opts->{output});exit 0}sub formatter {my$self=shift;my$homebank=shift;my$opts=shift || {};return App::HomeBank2Ledger::Formatter->new(type=>$opts->{format},account_width=>$opts->{account_width},name=>$homebank->title,file=>$homebank->file,)}sub convert_homebank_to_ledger {my$self=shift;my$homebank=shift;my$opts=shift || {};my$default_account_income='Income:Unknown';my$default_account_expenses='Expenses:Unknown';my$ledger=App::HomeBank2Ledger::Ledger->new;my$transactions=$homebank->sorted_transactions;my$accounts=$homebank->accounts;my$categories=$homebank->categories;my@budget;for my$account (@$accounts){my$type=$ACCOUNT_TYPES{$account->{type}}|| $UNKNOWN_ACCOUNT;$account->{ledger_name}="${type}:$account->{name}"}for my$category (@$categories){my$type=$category->{flags}{income}? 'Income' : 'Expenses';my$full_name=$homebank->full_category_name($category->{key});$category->{ledger_name}="${type}:${full_name}";if ($opts->{budget}&& $category->{flags}{budget}){for my$month_num ($category->{flags}{custom}? (1 .. 12): 0){my$amount=$category->{budget_amounts}[$month_num]|| 0;next if!$amount &&!$category->{flags}{forced};$budget[$month_num]{$category->{ledger_name}}=$amount}}}for my$item (@$accounts,@$categories){while (my ($re,$replacement)=each %{$opts->{rename_accounts}}){$item->{ledger_name}=~ s/$re/$replacement/}for my$re (@{$opts->{exclude_accounts}}){$item->{excluded}=1 if$item->{ledger_name}=~ /$re/}}while (my ($re,$replacement)=each %{$opts->{rename_accounts}}){$default_account_income =~ s/$re/$replacement/;$default_account_expenses =~ s/$re/$replacement/}my$has_initial_balance=grep {$_->{initial}&&!$_->{excluded}}@$accounts;if ($opts->{accounts}){my@accounts=map {$_->{ledger_name}}grep {!$_->{excluded}}@$accounts,@$categories;push@accounts,$default_account_income if!grep {$_ eq $default_account_income}@accounts;push@accounts,$default_account_expenses if!grep {$_ eq $default_account_expenses}@accounts;push@accounts,$OPENING_BALANCES_ACCOUNT if$has_initial_balance;$ledger->add_accounts(@accounts)}if ($opts->{payees}){my$payees=$homebank->payees;my@payees=map {$_->{name}}@$payees;$ledger->add_payees(@payees)}if ($opts->{tags}){my$tags=$homebank->tags;$ledger->add_tags(@$tags)}my%commodities;for my$currency (@{$homebank->currencies}){my$commodity={symbol=>$currency->{symbol},format=>$homebank->format_amount(1_000,$currency),iso=>$currency->{iso},name=>$currency->{name},};$commodities{$currency->{key}}={%$commodity,syprf=>$currency->{syprf},dchar=>$currency->{dchar},gchar=>$currency->{gchar},frac=>$currency->{frac},};$ledger->add_commodities($commodity)if$opts->{commodities}}my$first_date;if ($has_initial_balance){$first_date=$opts->{opening_date}|| $transactions->[0]{date};if ($first_date !~ /^\d{4}-\d{2}-\d{2}$/){die "Opening date must be in the form YYYY-MM-DD.\n"}my@postings;for my$account (@$accounts){next if!$account->{initial}|| $account->{excluded};push@postings,{account=>$account->{ledger_name},amount=>$account->{initial},commodity=>$commodities{$account->{currency}},}}push@postings,{account=>$OPENING_BALANCES_ACCOUNT,};$ledger->add_transactions({date=>$first_date,payee=>'Opening Balance',status=>'cleared',postings=>\@postings,})}if ($opts->{budget}){my ($first_year)=$first_date =~ /^(\d{4})/;for my$month_num (0 .. 12){next if!$budget[$month_num];my$payee='Monthly';if (0 < $month_num){my$year=$first_year;$year += 1 if sprintf('%04d-%02d-99',$first_year,$month_num)lt $first_date;my$date=sprintf('%04d-%02d',$year,$month_num);$payee="Every 12 months from ${date}"}my@postings;for my$account (sort keys %{$budget[$month_num]}){my$amount=$budget[$month_num]{$account};push@postings,{account=>$account,amount=>-$amount,commodity=>$commodities{$homebank->base_currency},}}push@postings,{account=>'Assets',};$ledger->add_transactions({date=>'~',payee=>$payee,postings=>\@postings,})}}my%seen;TRANSACTION: for my$transaction (@$transactions){next if$seen{$transaction->{transfer_key}|| ''};my$account=$homebank->find_account_by_key($transaction->{account});my$amount=$transaction->{amount};my$status=$STATUS_SYMBOLS{$transaction->{status}|| ''}|| '';my$paymode=$transaction->{paymode}|| '';my$memo=$transaction->{wording}|| '';my$payee=$homebank->find_payee_by_key($transaction->{payee});my$tags=_split_tags($transaction->{tags});my@postings;push@postings,{account=>$account->{ledger_name},amount=>$amount,commodity=>$commodities{$account->{currency}},payee=>$payee->{name},note=>$memo,status=>$status,tags=>$tags,};if ($paymode eq 'internaltransfer'){my$paired_transaction=$homebank->find_transaction_transfer_pair($transaction);my$dst_account=$homebank->find_account_by_key($transaction->{dst_account});if (!$dst_account){if ($paired_transaction){$dst_account=$homebank->find_account_by_key($paired_transaction->{account})}if (!$dst_account){warn "Skipping internal transfer transaction with no destination account.\n";next TRANSACTION}}$seen{$transaction->{transfer_key}}++ if$transaction->{transfer_key};$seen{$paired_transaction->{transfer_key}}++ if$paired_transaction->{transfer_key};my$paired_payee=$homebank->find_payee_by_key($paired_transaction->{payee});push@postings,{account=>$dst_account->{ledger_name},amount=>$paired_transaction->{amount}|| -$transaction->{amount},commodity=>$commodities{$dst_account->{currency}},payee=>$paired_payee->{name},note=>$paired_transaction->{wording}|| '',status=>$STATUS_SYMBOLS{$paired_transaction->{status}|| ''}|| $status,tags=>_split_tags($paired_transaction->{tags}),}}elsif ($transaction->{flags}{split}){my@amounts=split(/\|\|/,$transaction->{split_amount}|| '');my@memos=split(/\|\|/,$transaction->{split_memo}|| '');my@categories=split(/\|\|/,$transaction->{split_category}|| '');for (my$i=0;$amounts[$i];++$i){my$amount=-$amounts[$i];my$category=$homebank->find_category_by_key($categories[$i]);my$memo=$memos[$i]|| '';my$other_account=$category ? $category->{ledger_name}: $amount < 0 ? $default_account_income : $default_account_expenses;push@postings,{account=>$other_account,commodity=>$commodities{$account->{currency}},amount=>$amount,payee=>$payee->{name},note=>$memo,status=>$status,tags=>$tags,}}}else {my$amount=-$transaction->{amount};my$category=$homebank->find_category_by_key($transaction->{category});my$other_account=$category ? $category->{ledger_name}: $amount < 0 ? $default_account_income : $default_account_expenses;push@postings,{account=>$other_account,commodity=>$commodities{$account->{currency}},amount=>$amount,payee=>$payee->{name},note=>$memo,status=>$status,tags=>$tags,}}for my$posting (@postings){for my$re (@{$opts->{exclude_accounts}}){next TRANSACTION if$posting->{account}=~ /$re/}}$ledger->add_transactions({date=>$transaction->{date},payee=>$payee->{name},memo=>$memo,postings=>\@postings,})}return$ledger}sub print_to_file {my$self=shift;my$str=shift;my$filepath=shift;my$out_fh=\*STDOUT;if ($filepath){open($out_fh,'>',$filepath)or die "open failed: $!"}print$out_fh $str}sub parse_args {my$self=shift;my@args=@_;my%opts=(version=>0,help=>0,manual=>0,input=>undef,output=>undef,format=>'ledger',account_width=>40,accounts=>1,payees=>1,tags=>1,commodities=>1,budget=>1,opening_date=>'',rename_accounts=>{},exclude_accounts=>[],);GetOptionsFromArray(\@args,'version|V'=>\$opts{version},'help|h|?'=>\$opts{help},'manual|man'=>\$opts{manual},'input|file|i=s'=>\$opts{input},'output|o=s'=>\$opts{output},'format|f=s'=>\$opts{format},'account-width=i'=>\$opts{account_width},'accounts!'=>\$opts{accounts},'payees!'=>\$opts{payees},'tags!'=>\$opts{tags},'commodities!'=>\$opts{commodities},'budget!'=>\$opts{budget},'opening-date=s'=>\$opts{opening_date},'rename-account|r=s'=>\%{$opts{rename_accounts}},'exclude-account|x=s'=>\@{$opts{exclude_accounts}},)or pod2usage(-exitval=>1,-verbose=>99,-sections=>[qw(SYNOPSIS OPTIONS)]);$opts{input}=shift@args if!$opts{input};$opts{budget}=0 if lc($opts{format})ne 'ledger';return \%opts}sub _split_tags {my$tags=shift;return [split(/\h+/,$tags || '')]}1; APP_HOMEBANK2LEDGER $fatpacked{"App/HomeBank2Ledger/Formatter.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER'; package App::HomeBank2Ledger::Formatter;use warnings;use strict;use Module::Load;use Module::Pluggable search_path=>[__PACKAGE__],sub_name=>'available_formatters';our$VERSION='0.006';sub _croak {require Carp;Carp::croak(@_)}sub new {my$class=shift;my%args=@_;my$package=__PACKAGE__;if ($class eq $package and my$type=$args{type}){for my$formatter ($class->available_formatters){next if lc($formatter)ne lc("${package}::${type}");$class=$formatter;load$class;last}_croak('Invalid formatter type')if$class eq $package}return bless {%args},$class}sub format {die "Unimplemented\n"}sub type {shift->{type}}sub name {shift->{name}}sub file {shift->{file}}sub account_width {shift->{account_width}|| 40}1; APP_HOMEBANK2LEDGER_FORMATTER $fatpacked{"App/HomeBank2Ledger/Formatter/Beancount.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT'; package App::HomeBank2Ledger::Formatter::Beancount;use v5.10.1;use warnings;use strict;use App::HomeBank2Ledger::Util qw(commify rtrim);use Scalar::Util qw(looks_like_number);use parent 'App::HomeBank2Ledger::Formatter';our$VERSION='0.006';my%STATUS_SYMBOLS=(cleared=>'*',pending=>'!',);my$UNKNOWN_DATE='0001-01-01';sub _croak {require Carp;Carp::croak(@_)}sub format {my$self=shift;my$ledger=shift;my@out=($self->format_header,$self->format_accounts($ledger),$self->format_commodities($ledger),$self->format_transactions($ledger),);return join($/,map {rtrim($_)}@out)}sub format_header {my$self=shift;my@out;if (my$name=$self->name){push@out,"; Name: $name"}if (my$file=$self->file){push@out,"; File: $file"}push@out,'';return@out}sub format_accounts {my$self=shift;my$ledger=shift;my@out;for my$account (sort @{$ledger->accounts}){my$oldest_transaction=$self->_find_oldest_transaction_by_account($account,$ledger);my$account_date=$oldest_transaction->{date}|| $UNKNOWN_DATE;$account=$self->_format_account($account);push@out,"${account_date} open ${account}"}push@out,'';return@out}sub format_commodities {my$self=shift;my$ledger=shift;my@out;for my$commodity (@{$ledger->commodities}){my$oldest_transaction=$self->_find_oldest_transaction_by_commodity($commodity,$ledger);my$commodity_date=$oldest_transaction->{date}|| $UNKNOWN_DATE;push@out,"${commodity_date} commodity $commodity->{iso}";push@out,' name: '.$self->_format_string($commodity->{name})if$commodity->{name}}push@out,'';return@out}sub format_transactions {my$self=shift;my$ledger=shift;my@out;for my$transaction (@{$ledger->transactions}){push@out,$self->_format_transaction($transaction)}return@out}sub _format_transaction {my$self=shift;my$transaction=shift;my$account_width=$self->account_width;my$date=$transaction->{date};my$status=$transaction->{status};my$payee=$transaction->{payee}|| '';my$memo=$transaction->{memo}|| '';my@postings=@{$transaction->{postings}};my@out;my$status_symbol=$STATUS_SYMBOLS{$status || ''};if (!$status_symbol){my%posting_statuses=map {($_->{status}|| '')=>1}@postings;if (keys(%posting_statuses)==1){my ($status)=keys%posting_statuses;$status_symbol=$STATUS_SYMBOLS{$status || 'none'}|| ''}}push@out,sprintf('%s%s%s%s',$date,$status_symbol && ' '.$status_symbol || ' *',($payee || $memo)&& ' '.$self->_format_string($payee),$memo && ' '.$self->_format_string($memo),);if (my%tags=map {$_=>1}map {@{$_->{tags}|| []}}@postings){my@tags=map {"#$_"}keys%tags;$out[-1].= ' '.join(' ',@tags)}my$metadata=$transaction->{metadata}|| {};for my$key (sort keys %$metadata){my$value=looks_like_number($metadata->{$key})? $metadata->{$key}: $self->_format_string($metadata->{$key});push@out," ; ${key}: ${value}"}for my$posting (@postings){my@line;my$posting_status_symbol='';if (!$status_symbol){$posting_status_symbol=$STATUS_SYMBOLS{$posting->{status}|| ''}|| ''}my$account=$self->_format_account($posting->{account});push@line,($posting_status_symbol ? " $posting_status_symbol " : ' ');push@line,sprintf("\%-${account_width}s",$account);push@line,' ';if (defined$posting->{amount}){push@line,$self->_format_amount($posting->{amount},$posting->{commodity});my$lot_price=$posting->{lot_price};my$lot_date=$posting->{lot_date};my$lot_ref=$posting->{lot_ref};if ($lot_price || $lot_date || $lot_ref){push@line,' {',join(', ',$lot_price ? $self->_format_amount($lot_price->{amount},$lot_price->{commodity}): (),$lot_date ? $lot_date : (),$lot_ref ? $self->_format_string($lot_ref): (),),'}'}if (my$cost=$posting->{total_cost}// $posting->{cost}){my$is_total=defined$posting->{total_cost};my$cost_symbol=$is_total ? '@@' : '@';push@line,' ',$cost_symbol,' ',$self->_format_amount($cost->{amount},$cost->{commodity})}}push@out,join('',@line);my$metadata=$posting->{metadata}|| {};for my$key (sort keys %$metadata){my$value=looks_like_number($metadata->{$key})? $metadata->{$key}: $self->_format_string($metadata->{$key});push@out," ; ${key}: ${value}"}}push@out,'';return@out}sub _format_account {my$self=shift;my$account=shift;$account =~ s/[^A-Za-z0-9:]+/-/g;$account =~ s/-+/-/g;$account =~ s/(?:^|(?<=:))([a-z])/uc($1)/eg;return$account}sub _format_string {my$self=shift;my$str=shift;$str =~ s/"/\\"/g;return "\"$str\""}sub _format_amount {my$self=shift;my$amount=shift;my$commodity=shift or _croak 'Must provide a valid currency';my$format="\% .$commodity->{frac}f";my ($whole,$fraction)=split(/\./,sprintf($format,$amount));my$num=join('.',commify($whole),$fraction);$num="$num $commodity->{iso}";return$num}sub _find_oldest_transaction_by_account {my$self=shift;my$account=shift;my$ledger=shift;$account=$self->_format_account($account);my$oldest=$self->{oldest_transaction_by_account};if (!$oldest){for my$transaction (@{$ledger->transactions}){for my$posting (@{$transaction->{postings}}){my$account=$self->_format_account($posting->{account});if ($transaction->{date}lt ($oldest->{$account}{date}|| '9999-99-99')){$oldest->{$account}=$transaction}}}$self->{oldest_transaction_by_account}=$oldest}return$oldest->{$account}}sub _find_oldest_transaction_by_commodity {my$self=shift;my$commodity=shift;my$ledger=shift;my$oldest=$self->{oldest_transaction_by_commodity};if (!$oldest){for my$transaction (@{$ledger->transactions}){for my$posting (@{$transaction->{postings}}){my$symbol=$posting->{commodity}{symbol};next if!$symbol;if ($transaction->{date}lt ($oldest->{$symbol}{date}|| '9999-99-99')){$oldest->{$symbol}=$transaction}}}$self->{oldest_transaction_by_commodity}=$oldest}return$oldest->{$commodity->{symbol}}}1; APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT $fatpacked{"App/HomeBank2Ledger/Formatter/Ledger.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_FORMATTER_LEDGER'; package App::HomeBank2Ledger::Formatter::Ledger;use v5.10.1;use warnings;use strict;use App::HomeBank2Ledger::Util qw(commify rtrim);use parent 'App::HomeBank2Ledger::Formatter';our$VERSION='0.006';my%STATUS_SYMBOLS=(cleared=>'*',pending=>'!',);sub _croak {require Carp;Carp::croak(@_)}sub format {my$self=shift;my$ledger=shift;my@out=($self->format_header,$self->format_accounts($ledger),$self->format_commodities($ledger),$self->format_payees($ledger),$self->format_tags($ledger),$self->format_transactions($ledger),);return join($/,map {rtrim($_)}@out)}sub format_header {my$self=shift;my@out;if (my$name=$self->name){push@out,"; Name: $name"}if (my$file=$self->file){push@out,"; File: $file"}push@out,'';return@out}sub format_accounts {my$self=shift;my$ledger=shift;my@out;push@out,map {"account $_"}sort @{$ledger->accounts};push@out,'';return@out}sub format_commodities {my$self=shift;my$ledger=shift;my@out;for my$commodity (@{$ledger->commodities}){push@out,"commodity $commodity->{symbol}";push@out," note $commodity->{name}" if$commodity->{name};push@out," format $commodity->{format}" if$commodity->{format};push@out," alias $commodity->{iso}" if$commodity->{iso}}push@out,'';return@out}sub format_payees {my$self=shift;my$ledger=shift;my@out;push@out,map {"payee $_"}sort @{$ledger->payees};push@out,'';return@out}sub format_tags {my$self=shift;my$ledger=shift;my@out;push@out,map {"tag $_"}sort @{$ledger->tags};push@out,'';return@out}sub format_transactions {my$self=shift;my$ledger=shift;my@out;for my$transaction (@{$ledger->transactions}){push@out,$self->_format_transaction($transaction)}return@out}sub _format_transaction {my$self=shift;my$transaction=shift;my$account_width=$self->account_width;my$date=$transaction->{date};my$status=$transaction->{status};my$payee=$self->_format_string($transaction->{payee}|| '');my$memo=$self->_format_string($transaction->{memo}|| '');my@postings=@{$transaction->{postings}};my@out;my$status_symbol=$STATUS_SYMBOLS{$status || ''};if (!$status_symbol){my%posting_statuses=map {($_->{status}|| '')=>1}@postings;if (keys(%posting_statuses)==1){my ($status)=keys%posting_statuses;$status_symbol=$STATUS_SYMBOLS{$status || 'none'}|| ''}}$payee =~ s/(?: )|\t;/ ;/g;push@out,sprintf('%s%s%s%s',$date,$status_symbol && " ${status_symbol}",$payee && " $payee",$memo && " ; $memo",);my$metadata=$transaction->{metadata}|| {};for my$key (sort keys %$metadata){my$value=$self->_format_string($metadata->{$key});push@out," ; ${key}: ${value}"}for my$posting (@postings){my@line;my$posting_status_symbol='';if (!$status_symbol){$posting_status_symbol=$STATUS_SYMBOLS{$posting->{status}|| ''}|| ''}push@line,($posting_status_symbol ? " $posting_status_symbol " : ' ');push@line,sprintf("\%-${account_width}s",$posting->{account});push@line,' ';if (defined$posting->{amount}){push@line,$self->_format_amount($posting->{amount},$posting->{commodity});if (my$price=$posting->{lot_price}){my$is_fixed=$posting->{lot_fixed};my$fixed_symbol=$is_fixed ? '=' : '';push@line," {${fixed_symbol}",$self->_format_amount($price->{amount},$price->{commodity}),'}'}if (my$lot_date=$posting->{lot_date}){push@line," [$posting->{lot_date}]"}if (my$cost=$posting->{total_cost}// $posting->{cost}){my$is_total=defined$posting->{total_cost};my$cost_symbol=$is_total ? '@@' : '@';push@line,' ',$cost_symbol,' ',$self->_format_amount($cost->{amount},$cost->{commodity})}}if (my$note=$posting->{note}){$note=$self->_format_string($note);push@line," ; $note" if$note ne $memo}push@out,join('',@line);my$metadata=$posting->{metadata}|| {};for my$key (sort keys %$metadata){my$value=$self->_format_string($metadata->{$key});push@out," ; ${key}: ${value}"}if (my$posting_payee=$posting->{payee}){$posting_payee=$self->_format_string($posting_payee);push@out," ; Payee: $posting_payee" if$posting_payee ne $payee}if (my@tags=@{$posting->{tags}|| []}){push@out,' ; :'.join(':',@tags).':'}}push@out,'';return@out}sub _format_string {my$self=shift;my$str=shift;$str =~ s/\v//g;return$str}sub _quote_string {my$self=shift;my$str=shift;$str =~ s/"/\\"/g;return "\"$str\""}sub _format_amount {my$self=shift;my$amount=shift;my$commodity=shift or _croak 'Must provide a valid currency';my$format="\% .$commodity->{frac}f";my ($whole,$fraction)=split(/\./,sprintf($format,$amount));my$num=join($commodity->{dchar},commify($whole,$commodity->{gchar}),$fraction);my$symbol=$commodity->{symbol};$symbol=$self->_quote_string($symbol)if$symbol =~ /[0-9\s]/;$num=$commodity->{syprf}? "$symbol $num" : "$num $symbol";return$num}1; APP_HOMEBANK2LEDGER_FORMATTER_LEDGER $fatpacked{"App/HomeBank2Ledger/Ledger.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_LEDGER'; package App::HomeBank2Ledger::Ledger;use warnings;use strict;our$VERSION='0.006';sub new {my$class=shift;my%args=@_;return bless {%args},$class}sub accounts {shift->{accounts}|| []}sub commodities {shift->{commodities}|| []}sub payees {shift->{payees}|| []}sub tags {shift->{tags}|| []}sub transactions {shift->{transactions}|| []}sub add_accounts {my$self=shift;push @{$self->{accounts}},@_}sub add_commodities {my$self=shift;push @{$self->{commodities}},@_}sub add_payees {my$self=shift;push @{$self->{payees}},@_}sub add_tags {my$self=shift;push @{$self->{tags}},@_}sub add_transactions {my$self=shift;push @{$self->{transactions}},@_}1; APP_HOMEBANK2LEDGER_LEDGER $fatpacked{"App/HomeBank2Ledger/Util.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'APP_HOMEBANK2LEDGER_UTIL'; package App::HomeBank2Ledger::Util;use warnings;use strict;use Exporter qw(import);our$VERSION='0.006';our@EXPORT_OK=qw(commify rtrim);sub commify {my$num=shift;my$comma=shift || ',';my$str=reverse$num;$str =~ s/(\d\d\d)(?=\d)(?!\d*\.)/$1$comma/g;return scalar reverse$str}sub rtrim {my$str=shift;$str =~ s/\h+$//;return$str}1; APP_HOMEBANK2LEDGER_UTIL $fatpacked{"File/HomeBank.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'FILE_HOMEBANK'; package File::HomeBank;use warnings;use strict;use App::HomeBank2Ledger::Util qw(commify);use Exporter qw(import);use Scalar::Util qw(refaddr);use Time::Piece;use XML::Entities;use XML::Parser::Lite;our$VERSION='0.006';our@EXPORT_OK=qw(parse_string parse_file);my%ACCOUNT_TYPES=(0=>'none',1=>'bank',2=>'cash',3=>'asset',4=>'creditcard',5=>'liability',6=>'stock',7=>'mutualfund',8=>'income',9=>'expense',10=>'equity',);my%ACCOUNT_FLAGS=(0=>'oldbudget',1=>'closed',2=>'added',3=>'changed',4=>'nosummary',5=>'nobudget',6=>'noreport',);my%CURRENCY_FLAGS=(1=>'custom',);my%CATEGORY_FLAGS=(0=>'sub',1=>'income',2=>'custom',3=>'budget',4=>'forced',);my%TRANSACTION_FLAGS=(0=>'oldvalid',1=>'income',2=>'auto',3=>'added',4=>'changed',5=>'oldremind',6=>'cheq2',7=>'limit',8=>'split',);my%TRANSACTION_STATUSES=(0=>'none',1=>'cleared',2=>'reconciled',3=>'remind',4=>'void',);my%TRANSACTION_PAYMODES=(0=>'none',1=>'creditcard',2=>'check',3=>'cash',4=>'transfer',5=>'internaltransfer',6=>'debitcard',7=>'repeatpayment',8=>'epayment',9=>'deposit',10=>'fee',11=>'directdebit',);sub _croak {require Carp;Carp::croak(@_)}sub _usage {_croak("Usage: @_\n")}my%CACHE;sub new {my$class=shift;my%args=@_;my$self;if (my$filepath=$args{file}){$self=parse_file($filepath);$self->{file}=$filepath}elsif (my$str=$args{string}){$self=parse_string($str)}else {_usage(q{File::HomeBank->new(string => $str)})}return bless$self,$class}sub DESTROY {my$self=shift;my$in_global_destruction=shift;delete$CACHE{refaddr($self)}if!$in_global_destruction}sub file {shift->{file}}sub title {shift->{properties}{title}}sub base_currency {shift->{properties}{currency}}sub accounts {shift->{accounts}|| []}sub categories {shift->{categories}|| []}sub currencies {shift->{currencies}|| []}sub payees {shift->{payees}|| []}sub transactions {shift->{transactions}|| []}sub tags {my$self=shift;my%tags;for my$transaction (@{$self->transactions}){for my$tag (split(/\h+/,$transaction->{tags}|| '')){$tags{$tag}=1}}return [keys%tags]}sub find_account_by_key {my$self=shift;my$key=shift or return;my$index=$CACHE{refaddr($self)}{account_by_key};if (!$index){for my$account (@{$self->accounts}){$index->{$account->{key}}=$account}$CACHE{refaddr($self)}{account_by_key}=$index}return$index->{$key}}sub find_currency_by_key {my$self=shift;my$key=shift or return;my$index=$CACHE{refaddr($self)}{currency_by_key};if (!$index){for my$currency (@{$self->currencies}){$index->{$currency->{key}}=$currency}$CACHE{refaddr($self)}{currency_by_key}=$index}return$index->{$key}}sub find_category_by_key {my$self=shift;my$key=shift or return;my$index=$CACHE{refaddr($self)}{category_by_key};if (!$index){for my$category (@{$self->categories}){$index->{$category->{key}}=$category}$CACHE{refaddr($self)}{category_by_key}=$index}return$index->{$key}}sub find_payee_by_key {my$self=shift;my$key=shift or return;my$index=$CACHE{refaddr($self)}{payee_by_key};if (!$index){for my$payee (@{$self->payees}){$index->{$payee->{key}}=$payee}$CACHE{refaddr($self)}{payee_by_key}=$index}return$index->{$key}}sub find_transactions_by_transfer_key {my$self=shift;my$key=shift or return;my$index=$CACHE{refaddr($self)}{transactions_by_transfer_key};if (!$index){for my$transaction (@{$self->transactions}){my$xfkey=$transaction->{transfer_key}or next;push @{$index->{$xfkey}||= []},$transaction}$CACHE{refaddr($self)}{transactions_by_transfer_key}=$index}return @{$index->{$key}|| []}}sub find_transaction_transfer_pair {my$self=shift;my$transaction=shift;return if$transaction->{paymode}ne 'internaltransfer';my$transfer_key=$transaction->{transfer_key};my@matching=grep {refaddr($_)!=refaddr($transaction)}$self->find_transactions_by_transfer_key($transfer_key);warn "Found more than two transactions with the same transfer key.\n" if 1 < @matching;return$matching[0]if@matching;warn "Found internal transfer with no tranfer key.\n" if!defined$transfer_key;my$dst_account=$self->find_account_by_key($transaction->{dst_account});if (!$dst_account){warn "Found internal transfer with no destination account.\n";return}my@candidates;for my$t (@{$self->transactions}){next if$t->{paymode}ne 'internaltransfer';next if$t->{account}!=$transaction->{dst_account};next if$t->{dst_account}!=$transaction->{account};next if$t->{amount}!=-$transaction->{amount};my@matching=$self->find_transactions_by_transfer_key($t->{transfer_key});next if 1 < @matching;push@candidates,$t}my$transaction_day=_ymd_to_julian($transaction->{date});my@ordered_candidates=map {$_->[1]}sort {$a->[0]<=> $b->[0]}map {[abs($transaction_day - _ymd_to_julian($_->{date})),$_]}@candidates;if (my$winner=$ordered_candidates[0]){my$key1=$transfer_key || '[no key]';my$key2=$winner->{transfer_key}|| '[no key]';warn "Paired orphaned internal transfer ${key1} and ${key2}.\n";return$winner}}sub sorted_transactions {my$self=shift;my$sorted_transactions=$CACHE{refaddr($self)}{sorted_transactions};if (!$sorted_transactions){$sorted_transactions=[sort {$a->{date}cmp $b->{date}}@{$self->transactions}];$CACHE{refaddr($self)}{sorted_transactions}=$sorted_transactions}return$sorted_transactions}sub full_category_name {my$self=shift;my$key=shift or return;my$cat=$self->find_category_by_key($key);my@categories=($cat);while (my$parent_key=$cat->{parent}){$cat=$self->find_category_by_key($parent_key);unshift@categories,$cat}return join(':',map {$_->{name}}@categories)}sub format_amount {my$self=shift;my$amount=shift;my$currency=shift || $self->base_currency;$currency=$self->find_currency_by_key($currency)if!ref($currency);_croak 'Must provide a valid currency' if!$currency;my$format="\% .$currency->{frac}f";my ($whole,$fraction)=split(/\./,sprintf($format,$amount));my$num=join($currency->{dchar},commify($whole,$currency->{gchar}),$fraction);$num=$currency->{syprf}? "$currency->{symbol} $num" : "$num $currency->{symbol}";return$num}sub parse_file {my$filepath=shift or _usage(q{parse_file($filepath)});open(my$fh,'<',$filepath)or die "open failed: $!";my$str_in=do {local $/;<$fh>};return parse_string($str_in)}sub parse_string {my$str=shift or die _usage(q{parse_string($str)});my%properties;my@accounts;my@payees;my@categories;my@currencies;my@transactions;my$xml_parser=XML::Parser::Lite->new(Handlers=>{Start=>sub {shift;my$node=shift;my%attr=@_;for my$key (keys%attr){$attr{$key}=_decode_xml_entities($attr{$key})}if ($node eq 'properties'){$attr{currency}=delete$attr{curr}if$attr{curr};%properties=%attr}elsif ($node eq 'account'){$attr{type}=$ACCOUNT_TYPES{$attr{type}|| ''}|| 'unknown';$attr{bank_name}=delete$attr{bankname}if$attr{bankname};$attr{currency}=delete$attr{curr}if$attr{curr};$attr{display_position}=delete$attr{pos}if$attr{pos};my$flags=delete$attr{flags}|| 0;while (my ($shift,$name)=each%ACCOUNT_FLAGS){$attr{flags}{$name}=$flags & (1 << $shift)? 1 : 0}push@accounts,\%attr}elsif ($node eq 'pay'){push@payees,\%attr}elsif ($node eq 'cur'){$attr{symbol}=delete$attr{symb}if$attr{symb};my$flags=delete$attr{flags}|| 0;while (my ($shift,$name)=each%CURRENCY_FLAGS){$attr{flags}{$name}=$flags & (1 << $shift)? 1 : 0}push@currencies,\%attr}elsif ($node eq 'cat'){my$flags=delete$attr{flags}|| 0;while (my ($shift,$name)=each%CATEGORY_FLAGS){$attr{flags}{$name}=$flags & (1 << $shift)? 1 : 0}for my$bnum (0 .. 12){$attr{budget_amounts}[$bnum]=delete$attr{"b$bnum"}if$attr{"b$bnum"}}push@categories,\%attr}elsif ($node eq 'ope'){$attr{paymode}=$TRANSACTION_PAYMODES{$attr{paymode}|| ''}|| 'unknown';$attr{status}=$TRANSACTION_STATUSES{delete$attr{st}}|| 'unknown';$attr{transfer_key}=delete$attr{kxfer}if$attr{kxfer};$attr{split_amount}=delete$attr{samt}if$attr{samt};$attr{split_memo}=delete$attr{smem}if$attr{smem};$attr{split_category}=delete$attr{scat}if$attr{scat};$attr{date}=_rdn_to_ymd($attr{date})if$attr{date};my$flags=delete$attr{flags}|| 0;while (my ($shift,$name)=each%TRANSACTION_FLAGS){$attr{flags}{$name}=$flags & (1 << $shift)? 1 : 0}push@transactions,\%attr}},},);$xml_parser->parse($str);return {properties=>\%properties,accounts=>\@accounts,payees=>\@payees,categories=>\@categories,currencies=>\@currencies,transactions=>\@transactions,}}sub _decode_xml_entities {my$str=shift;return$str if$str !~ /&(?:#\d+)|[A-Za-z0-9]+;/;return XML::Entities::decode('all',$str)}sub _rdn_to_unix_epoch {my$rdn=shift;my$jan01_1970=719163;return ($rdn - $jan01_1970)* 86400}sub _rdn_to_ymd {my$rdn=shift;my$epoch=_rdn_to_unix_epoch($rdn);my$time=gmtime($epoch);return$time->ymd};sub _ymd_to_julian {my$ymd=shift;my$t=Time::Piece->strptime($ymd,'%Y-%m-%d');return$t->julian_day}1; FILE_HOMEBANK s/^ //mg for values %fatpacked; my $class = 'FatPacked::'.(0+\%fatpacked); no strict 'refs'; *{"${class}::files"} = sub { keys %{$_[0]} }; if ($] < 5.008) { *{"${class}::INC"} = sub { if (my $fat = $_[0]{$_[1]}) { my $pos = 0; my $last = length $fat; return (sub { return 0 if $pos == $last; my $next = (1 + index $fat, "\n", $pos) || $last; $_ .= substr $fat, $pos, $next - $pos; $pos = $next; return 1; }); } }; } else { *{"${class}::INC"} = sub { if (my $fat = $_[0]{$_[1]}) { open my $fh, '<', \$fat or die "FatPacker error loading $_[1] (could be a perl installation issue?)"; return $fh; } return; }; } unshift @INC, bless \%fatpacked, $class; } # END OF FATPACK CODE use warnings; use strict; use App::HomeBank2Ledger; our $VERSION = '0.006'; # VERSION App::HomeBank2Ledger->main(@ARGV); __END__ =pod =encoding UTF-8 =head1 NAME homebank2ledger - A tool to convert HomeBank files to Ledger format =head1 VERSION version 0.006 =head1 SYNOPSIS homebank2ledger --input FILEPATH [--output FILEPATH] [--format FORMAT] [--version|--help|--manual] [--account-width NUM] [--accounts|--no-accounts] [--payees|--no-payees] [--tags|--no-tags] [--commodities|--no-commodities] [--opening-date DATE] [--rename-account STR]... [--exclude-account STR]... =head1 DESCRIPTION F converts L files to a format usable by L. It can also convert directly to the similar L format. This software is B, in early development. Its interface may change without notice. I wrote F because I have been maintaining my own personal finances using HomeBank (which is awesome) and I wanted to investigate using plain text accounting programs. It works well enough for my data, but you may be using HomeBank features that I don't so there may be cases this doesn't handle well or at all. Feel free to file a bug report. This script does NOT try to modify the original HomeBank files it converts from, so there won't be any crazy data loss bugs... but no warranty. =head2 Features =over 4 =item * Converts HomeBank accounts and categories into a typical set of double-entry accounts. =item * Retains HomeBank metadata, including payees and tags. =item * Offers some customization of the output ledger, like account renaming. =back This program is feature-complete in my opinion (well, almost -- see L), but if there is anything you think it could do to be even better, feedback is welcome; just file a bug report. Or fork the code and have fun! =head2 Use cases You can migrate the data you have in HomeBank so you can start maintaining your accounts in Ledger (or Beancount). Or if you don't plan to switch completely off of HomeBank, you can continue to maintain your accounts in HomeBank and use this script to also take advantage of the reports Ledger offers. =head1 INSTALL There are several ways to install F to your system. =head2 using cpanm You can install F using L. If you have a local perl (plenv, perlbrew, etc.), you can just do: cpanm App::Homebank2Ledger to install the F executable and its dependencies. The executable will be installed to your perl's bin path, like F<~/perl5/perlbrew/bin/homebank2ledger>. If you're installing to your system perl, you can do: cpanm --sudo App::Homebank2Ledger to install the F executable to a system directory, like F (depending on your perl). =head2 Downloading just the executable You may also choose to download F as a single executable, like this: curl -OL https://raw.githubusercontent.com/chazmcgarvey/homebank2ledger/solo/homebank2ledger chmod +x homebank2ledger =head2 For developers If you're a developer and want to hack on the source, clone the repository and pull the dependencies: git clone https://github.com/chazmcgarvey/homebank2ledger.git cd homebank2ledger make bootstrap # installs dependencies; requires cpanm =head1 OPTIONS =head2 --version Print the version and exit. Alias: C<-V> =head2 --help Print help/usage info and exit. Alias: C<-h>, C<-?> =head2 --manual Print the full manual and exit. Alias: C<--man> =head2 --input FILEPATH Specify the path to the HomeBank file to read (must already exist). Alias: C<--file>, C<-i> =head2 --output FILEPATH Specify the path to the Ledger file to write (may not exist yet). If not provided, the formatted ledger will be printed on C. Alias: C<-o> =head2 --format STR Specify the output file format. If provided, must be one of: =over 4 =item * ledger =item * beancount =back =head2 --account-width NUM Specify the number of characters to reserve for the account column in transactions. Adjusting this can provide prettier formatting of the output. Defaults to 40. =head2 --accounts Enables account declarations. Defaults to enabled; use C<--no-accounts> to disable. =head2 --payees Enables payee declarations. Defaults to enabled; use C<--no-payees> to disable. =head2 --tags Enables tag declarations. Defaults to enabled; use C<--no-tags> to disable. =head2 --commodities Enables commodity declarations. Defaults to enabled; use C<--no-commodities> to disable. =head2 --budget Enables budget transactions. Budget transactions are only supported by the Ledger format (for now). This option is silently ignored otherwise. Defaults to enabled; use C<--no-budget> to disable. =head2 --opening-date DATE Specify the opening date for the "opening balances" transaction. This transaction is created (if needed) to support HomeBank's ability to configure accounts with opening balances. Date must be in the form "YYYY-MM-DD". Defaults to the date of the first transaction. =head2 --rename-account STR Specifies a mapping for renaming accounts in the output. By default F tries to come up with sensible account names (based on your HomeBank accounts and categories) that fit into five root accounts: =over 4 =item * Assets =item * Liabilities =item * Equity =item * Income =item * Expenses =back The value of the argument must be of the form "REGEXP=REPLACEMENT". See L. Can be repeated to rename multiple accounts. =head2 --exclude-account STR Specifies an account that will not be included in the output. All transactions related to this account will be skipped. Can be repeated to exclude multiple accounts. =head1 EXAMPLES =head2 Basic usage # Convert homebank.xhb to a Ledger-compatible file: homebank2ledger path/to/homebank.xhb -o ledger.dat # Run the Ledger balance report: ledger -f ledger.dat balance You can also combine this into one command: homebank2ledger path/to/homebank.xhb | ledger -f - balance =head2 Account renaming With the L argument, you have some control over the resulting account structure. This may be useful in cases where the organization imposed (or encouraged) by HomeBank doesn't necessarily line up with an ideal double-entry structure. homebank2ledger path/to/homebank.xhb -o ledger.dat \ --rename-account '^Assets:Credit Union Savings$=Assets:Bank:Credit Union:Savings' \ --rename-account '^Assets:Credit Union Checking$=Assets:Bank:Credit Union:Checking' Multiple accounts can be renamed at the same time because the first part of the mapping is a regular expression. The above example could be written like this: homebank2ledger path/to/homebank.xhb -o ledger.dat \ --rename-account '^Assets:Credit Union =Assets:Bank:Credit Union:' You can also merge accounts by simple renaming multiple accounts to the same name: homebank2ledger path/to/homebank.xhb -o ledger.dat \ --rename-account '^Liabilities:Chase VISA$=Liabilities:All Credit Cards' \ --rename-account '^Liabilities:Amex$=Liabilities:All Credit Cards' If you need to do anything more complicated, of course you can edit the output after converting; it's just plain text. =head2 Beancount # Convert homebank.xhb to a Beancount-compatible file: homebank2ledger path/to/homebank.xhb -f beancount -o ledger.beancount # Run the balances report: bean-report ledger.beancount balances =head1 CAVEATS =over 4 =item * I didn't intend to make this a releasable robust product, so it's lacking tests. =item * Scheduled transactions are not (yet) converted. =item * There are some minor formatting tweaks I will make (e.g. consolidate transaction tags and payees) =back =head1 BUGS Please report any bugs or feature requests on the bugtracker website L When submitting a bug or request, please include a test-file or a patch to an existing test-file that illustrates the bug or desired feature. =head1 AUTHOR Charles McGarvey =head1 COPYRIGHT AND LICENSE This software is Copyright (c) 2019 by Charles McGarvey. This is free software, licensed under: The MIT (X11) License =cut