1 package App
::HomeBank2Ledger
;
2 # ABSTRACT: A tool to convert HomeBank files to Ledger format
5 use warnings FATAL
=> 'all'; # temp fatal all
8 use App
::HomeBank2Ledger
::Formatter
;
9 use App
::HomeBank2Ledger
::Ledger
;
11 use Getopt
::Long
2.38 qw(GetOptionsFromArray);
14 our $VERSION = '0.004'; # VERSION
16 my %ACCOUNT_TYPES = ( # map HomeBank account types to Ledger accounts
17 bank
=> 'Assets:Bank',
18 cash
=> 'Assets:Cash',
19 asset
=> 'Assets:Fixed Assets',
20 creditcard
=> 'Liabilities:Credit Card',
21 liability
=> 'Liabilities',
22 stock
=> 'Assets:Stock',
23 mutualfund
=> 'Assets:Mutual Fund',
25 expense
=> 'Expenses',
28 my %STATUS_SYMBOLS = (
30 reconciled
=> 'cleared',
33 my $UNKNOWN_ACCOUNT = 'Assets:Unknown';
34 my $OPENING_BALANCES_ACCOUNT = 'Equity:Opening Balances';
39 my $self = bless {}, $class;
41 my $opts = $self->parse_args(@_);
43 if ($opts->{version
}) {
44 print "homebank2ledger ${VERSION}\n";
48 pod2usage
(-exitval
=> 0, -verbose
=> 99, -sections
=> [qw(NAME SYNOPSIS OPTIONS)]);
50 if ($opts->{manual
}) {
51 pod2usage
(-exitval
=> 0, -verbose
=> 2);
53 if (!$opts->{input
}) {
54 print STDERR
"Input file is required.\n";
58 my $homebank = File
::HomeBank-
>new(file
=> $opts->{input
});
60 my $formatter = eval { $self->formatter($homebank, $opts) };
62 if ($err =~ /^Invalid formatter/) {
63 print STDERR
"Invalid format: $opts->{format}\n";
69 my $ledger = $self->convert_homebank_to_ledger($homebank, $opts);
71 $self->print_to_file($formatter->format($ledger), $opts->{output
});
80 my $opts = shift || {};
82 return App
::HomeBank2Ledger
::Formatter-
>new(
83 type
=> $opts->{format
},
84 account_width
=> $opts->{account_width
},
85 name
=> $homebank->title,
86 file
=> $homebank->file,
91 sub convert_homebank_to_ledger
{
94 my $opts = shift || {};
96 my $default_account_income = 'Income:Unknown';
97 my $default_account_expenses = 'Expenses:Unknown';
99 my $ledger = App
::HomeBank2Ledger
::Ledger-
>new;
101 my $transactions = $homebank->sorted_transactions;
102 my $accounts = $homebank->accounts;
103 my $categories = $homebank->categories;
105 # determine full Ledger account names
106 for my $account (@$accounts) {
107 my $type = $ACCOUNT_TYPES{$account->{type
}} || $UNKNOWN_ACCOUNT;
108 $account->{ledger_name
} = "${type}:$account->{name}";
110 for my $category (@$categories) {
111 my $type = $category->{flags
}{income
} ? 'Income' : 'Expenses';
112 my $full_name = $homebank->full_category_name($category->{key
});
113 $category->{ledger_name
} = "${type}:${full_name}";
116 # handle renaming and marking excluded accounts
117 for my $item (@$accounts, @$categories) {
118 while (my ($re, $replacement) = each %{$opts->{rename_accounts
}}) {
119 $item->{ledger_name
} =~ s/$re/$replacement/;
121 for my $re (@{$opts->{exclude_accounts
}}) {
122 $item->{excluded
} = 1 if $item->{ledger_name
} =~ /$re/;
125 while (my ($re, $replacement) = each %{$opts->{rename_accounts
}}) {
126 $default_account_income =~ s/$re/$replacement/;
127 $default_account_expenses =~ s/$re/$replacement/;
130 my $has_initial_balance = grep { $_->{initial
} && !$_->{excluded
} } @$accounts;
132 if ($opts->{accounts
}) {
133 my @accounts = map { $_->{ledger_name
} } grep { !$_->{excluded
} } @$accounts, @$categories;
135 push @accounts, $default_account_income, $default_account_expenses;
136 push @accounts, $OPENING_BALANCES_ACCOUNT if $has_initial_balance;
138 $ledger->add_accounts(@accounts);
141 if ($opts->{payees
}) {
142 my $payees = $homebank->payees;
143 my @payees = map { $_->{name
} } @$payees;
145 $ledger->add_payees(@payees);
149 my $tags = $homebank->tags;
151 $ledger->add_tags(@$tags);
156 for my $currency (@{$homebank->currencies}) {
158 symbol
=> $currency->{symbol
},
159 format
=> $homebank->format_amount(1_000, $currency),
160 iso
=> $currency->{iso
},
161 name
=> $currency->{name
},
163 $commodities{$currency->{key
}} = {
165 syprf
=> $currency->{syprf
},
166 dchar
=> $currency->{dchar
},
167 gchar
=> $currency->{gchar
},
168 frac
=> $currency->{frac
},
171 $ledger->add_commodities($commodity) if $opts->{commodities
};
174 if ($has_initial_balance) {
175 # transactions are sorted, so the first transaction is the oldest
176 my $first_date = $opts->{opening_date
} || $transactions->[0]{date
};
177 if ($first_date !~ /^\d{4}-\d{2}-\d{2}$/) {
178 die "Opening date must be in the form YYYY-MM-DD.\n";
183 for my $account (@$accounts) {
184 next if !$account->{initial
} || $account->{excluded
};
187 account
=> $account->{ledger_name
},
188 amount
=> $account->{initial
},
189 commodity
=> $commodities{$account->{currency
}},
194 account
=> $OPENING_BALANCES_ACCOUNT,
197 $ledger->add_transactions({
199 payee
=> 'Opening Balance',
201 postings
=> \
@postings,
208 for my $transaction (@$transactions) {
209 next if $seen{$transaction->{transfer_key
} || ''};
211 my $account = $homebank->find_account_by_key($transaction->{account
});
212 my $amount = $transaction->{amount
};
213 my $status = $STATUS_SYMBOLS{$transaction->{status
} || ''} || '';
214 my $paymode = $transaction->{paymode
} || ''; # internaltransfer
215 my $memo = $transaction->{wording
} || '';
216 my $payee = $homebank->find_payee_by_key($transaction->{payee
});
217 my $tags = _split_tags
($transaction->{tags
});
222 account
=> $account->{ledger_name
},
224 commodity
=> $commodities{$account->{currency
}},
225 payee
=> $payee->{name
},
231 if ($paymode eq 'internaltransfer') {
232 my $paired_transaction = $homebank->find_transaction_transfer_pair($transaction);
234 my $dst_account = $homebank->find_account_by_key($transaction->{dst_account
});
236 if ($paired_transaction) {
237 $dst_account = $homebank->find_account_by_key($paired_transaction->{account
});
240 warn "Skipping internal transfer transaction with no destination account.\n";
245 $seen{$transaction->{transfer_key
}}++ if $transaction->{transfer_key
};
246 $seen{$paired_transaction->{transfer_key
}}++ if $paired_transaction->{transfer_key
};
248 my $paired_payee = $homebank->find_payee_by_key($paired_transaction->{payee
});
251 account
=> $dst_account->{ledger_name
},
252 amount
=> $paired_transaction->{amount
} || -$transaction->{amount
},
253 commodity
=> $commodities{$dst_account->{currency
}},
254 payee
=> $paired_payee->{name
},
255 memo
=> $paired_transaction->{wording
} || '',
256 status
=> $STATUS_SYMBOLS{$paired_transaction->{status
} || ''} || $status,
257 tags
=> _split_tags
($paired_transaction->{tags
}),
260 elsif ($transaction->{flags
}{split}) {
261 my @amounts = split(/\|\|/, $transaction->{split_amount
} || '');
262 my @memos = split(/\|\|/, $transaction->{split_memo
} || '');
263 my @categories = split(/\|\|/, $transaction->{split_category
} || '');
265 for (my $i = 0; $amounts[$i]; ++$i) {
266 my $amount = -$amounts[$i];
267 my $category = $homebank->find_category_by_key($categories[$i]);
268 my $memo = $memos[$i] || '';
269 my $other_account = $category ? $category->{ledger_name
}
270 : $amount < 0 ? $default_account_income
271 : $default_account_expenses;
274 account
=> $other_account,
275 commodity
=> $commodities{$account->{currency
}},
277 payee
=> $payee->{name
},
284 else { # with or without category
285 my $amount = -$transaction->{amount
};
286 my $category = $homebank->find_category_by_key($transaction->{category
});
287 my $other_account = $category ? $category->{ledger_name
}
288 : $amount < 0 ? $default_account_income
289 : $default_account_expenses;
292 account
=> $other_account,
293 commodity
=> $commodities{$account->{currency
}},
295 payee
=> $payee->{name
},
302 # skip excluded accounts
303 for my $posting (@postings) {
304 for my $re (@{$opts->{exclude_accounts
}}) {
305 next TRANSACTION
if $posting->{account
} =~ /$re/;
309 $ledger->add_transactions({
310 date
=> $transaction->{date
},
311 payee
=> $payee->{name
},
313 postings
=> \
@postings,
324 my $filepath = shift;
326 my $out_fh = \
*STDOUT
;
328 open($out_fh, '>', $filepath) or die "open failed: $!";
351 rename_accounts
=> {},
352 exclude_accounts
=> [],
355 GetOptionsFromArray
(\
@args,
356 'version|V' => \
$opts{version
},
357 'help|h|?' => \
$opts{help
},
358 'manual|man' => \
$opts{manual
},
359 'input|file|i=s' => \
$opts{input
},
360 'output|o=s' => \
$opts{output
},
361 'format|f=s' => \
$opts{format
},
362 'account-width=i' => \
$opts{account_width
},
363 'accounts!' => \
$opts{accounts
},
364 'payees!' => \
$opts{payees
},
365 'tags!' => \
$opts{tags
},
366 'commodities!' => \
$opts{commodities
},
367 'opening-date=s' => \
$opts{opening_date
},
368 'rename-account|r=s' => \
%{$opts{rename_accounts
}},
369 'exclude-account|x=s' => \
@{$opts{exclude_accounts
}},
370 ) or pod2usage
(-exitval
=> 1, -verbose
=> 99, -sections
=> [qw(SYNOPSIS OPTIONS)]);
372 $opts{input
} = shift @args if !$opts{input
};
379 return [split(/\h+/, $tags || '')];
392 App::HomeBank2Ledger - A tool to convert HomeBank files to Ledger format
400 App::HomeBank2Ledger->main(@args);
404 This module is part of the L<homebank2ledger> script.
410 App::HomeBank2Ledger->main(@args);
412 Run the script and exit; does not return.
416 $formatter = $app->formatter($homebank, $opts);
418 Generate a L<App::HomeBank2Ledger::Formatter>.
420 =head2 convert_homebank_to_ledger
422 my $ledger = $app->convert_homebank_to_ledger($homebank, $opts);
424 Converts a L<File::HomeBank> to a L<App::HomeBank2Ledger::Ledger>.
428 $app->print_to_file($str);
429 $app->print_to_file($str, $filepath);
431 Print a string to a file (or STDOUT).
435 $opts = $app->parse_args(@args);
437 Parse command-line arguments.
441 Please report any bugs or feature requests on the bugtracker website
442 L<https://github.com/chazmcgarvey/homebank2ledger/issues>
444 When submitting a bug or request, please include a test-file or a
445 patch to an existing test-file that illustrates the bug or desired
450 Charles McGarvey <chazmcgarvey@brokenzipper.com>
452 =head1 COPYRIGHT AND LICENSE
454 This software is Copyright (c) 2019 by Charles McGarvey.
456 This is free software, licensed under:
458 The MIT (X11) License