Release 0.008
[chaz/homebank2ledger] / homebank2ledger
1 #!/usr/bin/env perl
2 # ABSTRACT: A tool to convert HomeBank files to Ledger format
3 # PODNAME: homebank2ledger
4
5
6
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'
9 BEGIN {
10 my %fatpacked;
11
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.008';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@postings;push@postings,{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_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;
14 APP_HOMEBANK2LEDGER
15
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.008';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
19
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.008';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;
22 APP_HOMEBANK2LEDGER_FORMATTER_BEANCOUNT
23
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.008';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;
26 APP_HOMEBANK2LEDGER_FORMATTER_LEDGER
27
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.008';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
31
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.008';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
35
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.008';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;
38 FILE_HOMEBANK
39
40 s/^ //mg for values %fatpacked;
41
42 my $class = 'FatPacked::'.(0+\%fatpacked);
43 no strict 'refs';
44 *{"${class}::files"} = sub { keys %{$_[0]} };
45
46 if ($] < 5.008) {
47 *{"${class}::INC"} = sub {
48 if (my $fat = $_[0]{$_[1]}) {
49 my $pos = 0;
50 my $last = length $fat;
51 return (sub {
52 return 0 if $pos == $last;
53 my $next = (1 + index $fat, "\n", $pos) || $last;
54 $_ .= substr $fat, $pos, $next - $pos;
55 $pos = $next;
56 return 1;
57 });
58 }
59 };
60 }
61
62 else {
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?)";
67 return $fh;
68 }
69 return;
70 };
71 }
72
73 unshift @INC, bless \%fatpacked, $class;
74 } # END OF FATPACK CODE
75
76
77
78 use warnings;
79 use strict;
80
81 use App::HomeBank2Ledger;
82
83 our $VERSION = '0.008'; # VERSION
84
85 App::HomeBank2Ledger->main(@ARGV);
86
87 __END__
88
89 =pod
90
91 =encoding UTF-8
92
93 =head1 NAME
94
95 homebank2ledger - A tool to convert HomeBank files to Ledger format
96
97 =head1 VERSION
98
99 version 0.008
100
101 =head1 SYNOPSIS
102
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]...
109
110 =head1 DESCRIPTION
111
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|http://furius.ca/beancount/> format.
115
116 This software is B<EXPERIMENTAL>, in early development. Its interface may change without notice.
117
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
123 warranty.
124
125 =head2 Features
126
127 =over 4
128
129 =item *
130
131 Converts HomeBank accounts and categories into a typical set of double-entry accounts.
132
133 =item *
134
135 Retains HomeBank metadata, including payees and tags.
136
137 =item *
138
139 Offers some customization of the output ledger, like account renaming.
140
141 =back
142
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!
146
147 =head2 Use cases
148
149 You can migrate the data you have in HomeBank so you can start maintaining your accounts in Ledger
150 (or Beancount).
151
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.
154
155 =head1 INSTALL
156
157 There are several ways to install F<homebank2ledger> to your system.
158
159 =head2 using cpanm
160
161 You can install F<homebank2ledger> using L<cpanm>. If you have a local perl (plenv, perlbrew, etc.),
162 you can just do:
163
164 cpanm App::Homebank2Ledger
165
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>.
168
169 If you're installing to your system perl, you can do:
170
171 cpanm --sudo App::Homebank2Ledger
172
173 to install the F<homebank2ledger> executable to a system directory, like
174 F</usr/local/bin/homebank2ledger> (depending on your perl).
175
176 =head2 Downloading just the executable
177
178 You may also choose to download F<homebank2ledger> as a single executable, like this:
179
180 curl -OL https://raw.githubusercontent.com/chazmcgarvey/homebank2ledger/solo/homebank2ledger
181 chmod +x homebank2ledger
182
183 =head2 For developers
184
185 If you're a developer and want to hack on the source, clone the repository and pull the
186 dependencies:
187
188 git clone https://github.com/chazmcgarvey/homebank2ledger.git
189 cd homebank2ledger
190 make bootstrap # installs dependencies; requires cpanm
191
192 =head1 OPTIONS
193
194 =head2 --version
195
196 Print the version and exit.
197
198 Alias: C<-V>
199
200 =head2 --help
201
202 Print help/usage info and exit.
203
204 Alias: C<-h>, C<-?>
205
206 =head2 --manual
207
208 Print the full manual and exit.
209
210 Alias: C<--man>
211
212 =head2 --input FILEPATH
213
214 Specify the path to the HomeBank file to read (must already exist).
215
216 Alias: C<--file>, C<-i>
217
218 =head2 --output FILEPATH
219
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>.
222
223 Alias: C<-o>
224
225 =head2 --format STR
226
227 Specify the output file format. If provided, must be one of:
228
229 =over 4
230
231 =item *
232
233 ledger
234
235 =item *
236
237 beancount
238
239 =back
240
241 =head2 --account-width NUM
242
243 Specify the number of characters to reserve for the account column in transactions. Adjusting this
244 can provide prettier formatting of the output.
245
246 Defaults to 40.
247
248 =head2 --accounts
249
250 Enables account declarations.
251
252 Defaults to enabled; use C<--no-accounts> to disable.
253
254 =head2 --payees
255
256 Enables payee declarations.
257
258 Defaults to enabled; use C<--no-payees> to disable.
259
260 =head2 --tags
261
262 Enables tag declarations.
263
264 Defaults to enabled; use C<--no-tags> to disable.
265
266 =head2 --commodities
267
268 Enables commodity declarations.
269
270 Defaults to enabled; use C<--no-commodities> to disable.
271
272 =head2 --budget
273
274 Enables budget transactions.
275
276 Budget transactions are only supported by the Ledger format (for now). This option is silently
277 ignored otherwise.
278
279 Defaults to enabled; use C<--no-budget> to disable.
280
281 =head2 --opening-date DATE
282
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.
285
286 Date must be in the form "YYYY-MM-DD". Defaults to the date of the first transaction.
287
288 =head2 --rename-account STR
289
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
292 root accounts:
293
294 =over 4
295
296 =item *
297
298 Assets
299
300 =item *
301
302 Liabilities
303
304 =item *
305
306 Equity
307
308 =item *
309
310 Income
311
312 =item *
313
314 Expenses
315
316 =back
317
318 The value of the argument must be of the form "REGEXP=REPLACEMENT". See L</EXAMPLES>.
319
320 Can be repeated to rename multiple accounts.
321
322 =head2 --exclude-account STR
323
324 Specifies an account that will not be included in the output. All transactions related to this
325 account will be skipped.
326
327 Can be repeated to exclude multiple accounts.
328
329 =head1 EXAMPLES
330
331 =head2 Basic usage
332
333 # Convert homebank.xhb to a Ledger-compatible file:
334 homebank2ledger path/to/homebank.xhb -o ledger.dat
335
336 # Run the Ledger balance report:
337 ledger -f ledger.dat balance
338
339 You can also combine this into one command:
340
341 homebank2ledger path/to/homebank.xhb | ledger -f - balance
342
343 =head2 Account renaming
344
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.
348
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'
352
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:
355
356 homebank2ledger path/to/homebank.xhb -o ledger.dat \
357 --rename-account '^Assets:Credit Union =Assets:Bank:Credit Union:'
358
359 You can also merge accounts by simple renaming multiple accounts to the same name:
360
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'
364
365 If you need to do anything more complicated, of course you can edit the output after converting;
366 it's just plain text.
367
368 =head2 Beancount
369
370 # Convert homebank.xhb to a Beancount-compatible file:
371 homebank2ledger path/to/homebank.xhb -f beancount -o ledger.beancount
372
373 # Run the balances report:
374 bean-report ledger.beancount balances
375
376 =head1 CAVEATS
377
378 =over 4
379
380 =item *
381
382 I didn't intend to make this a releasable robust product, so it's lacking tests.
383
384 =item *
385
386 Scheduled transactions are not (yet) converted.
387
388 =item *
389
390 There are some minor formatting tweaks I will make (e.g. consolidate transaction tags and payees)
391
392 =back
393
394 =head1 BUGS
395
396 Please report any bugs or feature requests on the bugtracker website
397 L<https://github.com/chazmcgarvey/homebank2ledger/issues>
398
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
401 feature.
402
403 =head1 AUTHOR
404
405 Charles McGarvey <chazmcgarvey@brokenzipper.com>
406
407 =head1 COPYRIGHT AND LICENSE
408
409 This software is Copyright (c) 2019 by Charles McGarvey.
410
411 This is free software, licensed under:
412
413 The MIT (X11) License
414
415 =cut
This page took 0.145852 seconds and 4 git commands to generate.